feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -17,7 +17,7 @@ class AgentAssetJsonRuleMixin:
|
||||
if rule_library not in RULE_LIBRARY_NAMES:
|
||||
raise ValueError("规则库目录不合法。")
|
||||
|
||||
rule_document = config_json.get("rule_document")
|
||||
rule_document = self._resolve_working_json_risk_rule_document(asset, config_json)
|
||||
if not isinstance(rule_document, dict):
|
||||
raise ValueError("规则资产缺少 rule_document 配置。")
|
||||
|
||||
@@ -26,6 +26,27 @@ class AgentAssetJsonRuleMixin:
|
||||
raise ValueError("规则资产缺少 JSON 文件名。")
|
||||
return rule_library, file_name
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_json_risk_rule_document(
|
||||
asset: AgentAsset,
|
||||
config_json: dict,
|
||||
) -> dict | None:
|
||||
revision = config_json.get("revision_draft")
|
||||
if isinstance(revision, dict):
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
working_version = str(asset.working_version or "").strip()
|
||||
published_version = str(asset.published_version or "").strip()
|
||||
revision_document = revision.get("rule_document")
|
||||
if (
|
||||
revision_version
|
||||
and revision_version == working_version
|
||||
and revision_version != published_version
|
||||
and isinstance(revision_document, dict)
|
||||
and str(revision_document.get("file_name") or "").strip()
|
||||
):
|
||||
return revision_document
|
||||
return config_json.get("rule_document")
|
||||
|
||||
def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
|
||||
79
server/src/app/services/agent_asset_risk_rule_feedback.py
Normal file
79
server/src/app/services/agent_asset_risk_rule_feedback.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.models.agent_asset import AgentAssetRuleFeedback
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleFeedbackCreate,
|
||||
AgentAssetRiskRuleFeedbackRead,
|
||||
)
|
||||
|
||||
|
||||
class AgentAssetRiskRuleFeedbackMixin:
|
||||
def create_risk_rule_feedback(
|
||||
self,
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleFeedbackCreate,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRiskRuleFeedbackRead:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
version = self._resolve_target_version(asset, payload.version)
|
||||
feedback = AgentAssetRuleFeedback(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
feedback_type=str(payload.feedback_type or "").strip(),
|
||||
subject_type=str(payload.subject_type or "").strip(),
|
||||
subject_key=str(payload.subject_key or "").strip(),
|
||||
subject_label=str(payload.subject_label or "").strip(),
|
||||
actual_result_json=self._safe_json_dict(payload.actual_result),
|
||||
expected_result_json=self._safe_json_dict(payload.expected_result),
|
||||
comment=str(payload.comment or "").strip(),
|
||||
payload_json=self._safe_json_dict(payload.payload),
|
||||
created_by=str(actor or "").strip() or "system",
|
||||
)
|
||||
created = self.repository.create_rule_feedback(feedback)
|
||||
self.audit_service.log_action(
|
||||
actor=created.created_by,
|
||||
action="create_risk_rule_feedback",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=None,
|
||||
after_json={
|
||||
"feedback_id": created.feedback_id,
|
||||
"version": created.version,
|
||||
"feedback_type": created.feedback_type,
|
||||
"subject_type": created.subject_type,
|
||||
"subject_key": created.subject_key,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
return AgentAssetRiskRuleFeedbackRead.model_validate(created)
|
||||
|
||||
def list_risk_rule_feedback(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[AgentAssetRiskRuleFeedbackRead]:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
target_version = self._resolve_target_version(asset, version) if version else None
|
||||
return [
|
||||
AgentAssetRiskRuleFeedbackRead.model_validate(item)
|
||||
for item in self.repository.list_rule_feedback(
|
||||
asset.id,
|
||||
version=target_version,
|
||||
status=status,
|
||||
limit=limit,
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _safe_json_dict(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
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
|
||||
404
server/src/app/services/agent_asset_risk_rule_regeneration.py
Normal file
404
server/src/app/services/agent_asset_risk_rule_regeneration.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.repositories.agent_asset import AgentAssetRepository
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleRegenerateRequest,
|
||||
)
|
||||
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.risk_rule_generation import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
RiskRuleGenerationService,
|
||||
)
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
class AgentAssetRiskRuleRegenerationService:
|
||||
"""重新把自然语言草稿或修订草稿解释为可执行 JSON 风险规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
|
||||
runtime_chat_service: RuntimeChatService | None = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.repository = AgentAssetRepository(db)
|
||||
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
|
||||
self.generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=self.rule_library_manager,
|
||||
runtime_chat_service=runtime_chat_service,
|
||||
)
|
||||
self.audit_service = AuditLogService(db)
|
||||
|
||||
def regenerate(
|
||||
self,
|
||||
asset_id: str,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAsset:
|
||||
asset = self._resolve_json_risk_asset(asset_id)
|
||||
if str(asset.published_version or "").strip():
|
||||
return self._regenerate_revision_draft(
|
||||
asset,
|
||||
body,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
return self._regenerate_unpublished_draft(
|
||||
asset,
|
||||
body,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _regenerate_unpublished_draft(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
if asset.status not in {AgentAssetStatus.DRAFT.value, AgentAssetStatus.FAILED.value}:
|
||||
raise ValueError("只有未上线草稿或生成失败规则可以重新生成。")
|
||||
|
||||
before = self._snapshot(asset)
|
||||
config = dict(asset.config_json or {})
|
||||
request = self._build_generation_request(asset, config, body.model_dump(exclude_unset=True))
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=asset.created_at)
|
||||
rule_code = self._stable_rule_code(asset, payload)
|
||||
payload["rule_code"] = rule_code
|
||||
file_name = f"{rule_code}.json"
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
version = str(asset.working_version or asset.current_version or "v0.1.0")
|
||||
now = datetime.now(UTC).isoformat()
|
||||
self._upsert_version(
|
||||
asset,
|
||||
version=version,
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
change_note="重新生成自然语言风险规则草稿。",
|
||||
actor=actor,
|
||||
)
|
||||
config.update(self._config_from_payload(payload, risk_score=risk_score, request=request))
|
||||
config.update(
|
||||
{
|
||||
"generation_status": "completed",
|
||||
"generation_completed_at": now,
|
||||
"last_operation": {"action": "regenerate", "actor": actor, "at": now},
|
||||
}
|
||||
)
|
||||
asset.code = rule_code
|
||||
asset.name = str(payload["name"])
|
||||
asset.description = str(payload["description"])
|
||||
asset.domain = str(request.get("business_domain") or AgentAssetDomain.EXPENSE.value)
|
||||
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[asset.domain])]
|
||||
asset.status = AgentAssetStatus.DRAFT.value
|
||||
asset.current_version = version
|
||||
asset.working_version = version
|
||||
asset.config_json = config
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="regenerate_risk_rule_draft",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return asset
|
||||
|
||||
def _regenerate_revision_draft(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
revision = self._resolve_revision_draft(asset)
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
before = self._snapshot(asset)
|
||||
config = dict(asset.config_json or {})
|
||||
request = self._build_generation_request(
|
||||
asset,
|
||||
config,
|
||||
body.model_dump(exclude_unset=True),
|
||||
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
|
||||
)
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=datetime.now(UTC))
|
||||
payload["rule_code"] = str(asset.code or payload["rule_code"]).strip()
|
||||
payload["enabled"] = False
|
||||
payload.setdefault("metadata", {})["revision_version"] = revision_version
|
||||
payload["metadata"]["revision_base_version"] = revision.get("base_version")
|
||||
file_name = f"{payload['rule_code']}.{revision_version.replace('.', '_')}.json"
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
revision.update(
|
||||
{
|
||||
"status": "generated",
|
||||
"generation_status": "completed",
|
||||
"generation_request": request,
|
||||
"generated_by": actor,
|
||||
"generated_at": now,
|
||||
"rule_code": payload["rule_code"],
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_score["level"],
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"flow_diagram_svg": payload.get("flow_diagram_svg"),
|
||||
"business_explanation": payload.get("metadata", {}).get("business_explanation"),
|
||||
}
|
||||
)
|
||||
config["revision_draft"] = revision
|
||||
config["last_operation"] = {
|
||||
"action": "regenerate_revision",
|
||||
"actor": actor,
|
||||
"at": now,
|
||||
"target_version": revision_version,
|
||||
}
|
||||
asset.working_version = revision_version
|
||||
asset.config_json = config
|
||||
self._upsert_version(
|
||||
asset,
|
||||
version=revision_version,
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
change_note=str(revision.get("change_reason") or "重新生成修订草稿"),
|
||||
actor=actor,
|
||||
)
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="regenerate_risk_rule_revision",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return asset
|
||||
|
||||
def _compile_payload(
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
created_at: datetime | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
body = AgentAssetRiskRuleGenerateRequest.model_validate(request)
|
||||
domain = body.business_domain.value
|
||||
natural_language = self.generator._clean_text(body.natural_language)
|
||||
rule_title = self.generator._clean_text(body.rule_title)
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
|
||||
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
fields = self.generator._resolve_fields(natural_language, domain=domain)
|
||||
draft = self.generator._compile_with_model(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
business_stage_label=business_stage_label,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
fields=fields,
|
||||
) or self.generator._build_fallback_draft(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
||||
draft = self.generator._align_draft_fields(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
||||
risk_score = calculate_risk_rule_score(
|
||||
natural_language=natural_language,
|
||||
draft=draft,
|
||||
fields=fields,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
requires_attachment=requires_attachment,
|
||||
)
|
||||
risk_level = str(risk_score["level"])
|
||||
draft = apply_risk_score_to_draft(draft, risk_score)
|
||||
payload = self.generator._build_rule_payload(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
business_stage_label=business_stage_label,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
fields=fields,
|
||||
created_at=created_at or datetime.now(UTC),
|
||||
actor=actor,
|
||||
requires_attachment=requires_attachment,
|
||||
rule_title=rule_title,
|
||||
risk_score=risk_score,
|
||||
)
|
||||
return payload, risk_score
|
||||
|
||||
def _resolve_json_risk_asset(self, asset_id: str) -> AgentAsset:
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise FileNotFoundError("风险规则不存在。")
|
||||
config = asset.config_json or {}
|
||||
if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk":
|
||||
raise ValueError("当前资产不是自然语言风险规则。")
|
||||
return asset
|
||||
|
||||
def _resolve_revision_draft(self, asset: AgentAsset) -> dict[str, Any]:
|
||||
config = dict(asset.config_json or {})
|
||||
revision = config.get("revision_draft")
|
||||
if not isinstance(revision, dict):
|
||||
raise ValueError("已上线规则需要先创建修订版本,再重新生成。")
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
if not revision_version or revision_version != str(asset.working_version or "").strip():
|
||||
raise ValueError("修订草稿版本与当前工作版本不一致。")
|
||||
if revision.get("status") not in {"draft", "generated", "failed"}:
|
||||
raise ValueError("当前修订草稿状态不允许重新生成。")
|
||||
return dict(revision)
|
||||
|
||||
@staticmethod
|
||||
def _build_generation_request(
|
||||
asset: AgentAsset,
|
||||
config: dict[str, Any],
|
||||
updates: dict[str, Any],
|
||||
*,
|
||||
base: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source = base if isinstance(base, dict) else config.get("generation_request")
|
||||
merged = dict(source if isinstance(source, dict) else {})
|
||||
merged.setdefault("business_domain", asset.domain or AgentAssetDomain.EXPENSE.value)
|
||||
merged.setdefault("business_stage", config.get("business_stage") or "reimbursement")
|
||||
merged.setdefault("expense_category", config.get("expense_category"))
|
||||
merged.setdefault("rule_title", asset.name)
|
||||
merged.setdefault("natural_language", asset.description)
|
||||
merged.setdefault("requires_attachment", bool(config.get("requires_attachment")))
|
||||
for key, value in updates.items():
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _stable_rule_code(asset: AgentAsset, payload: dict[str, Any]) -> str:
|
||||
current = str(asset.code or "").strip()
|
||||
if current and ".generating_" not in current:
|
||||
return current
|
||||
return str(payload.get("rule_code") or current).strip()
|
||||
|
||||
@staticmethod
|
||||
def _config_from_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
risk_score: dict[str, Any],
|
||||
request: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
file_name = f"{payload['rule_code']}.json"
|
||||
return {
|
||||
"severity": risk_score["level"],
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_score["level"],
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"risk_score_detail": risk_score,
|
||||
"requires_attachment": bool(request.get("requires_attachment")),
|
||||
"detail_mode": "json_risk",
|
||||
"business_stage": request.get("business_stage"),
|
||||
"business_stage_label": payload.get("metadata", {}).get("business_stage_label"),
|
||||
"expense_category": request.get("expense_category"),
|
||||
"expense_category_label": payload.get("metadata", {}).get("expense_category_label"),
|
||||
"risk_category": payload.get("risk_category"),
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"ontology_signal": payload.get("ontology_signal"),
|
||||
"evaluator": payload.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"source_ref": "自然语言风险规则",
|
||||
"generation_request": request,
|
||||
"flow_diagram_svg": payload.get("flow_diagram_svg"),
|
||||
}
|
||||
|
||||
def _upsert_version(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
content: str,
|
||||
change_note: str,
|
||||
actor: str,
|
||||
) -> None:
|
||||
existing = self.repository.get_version(asset.id, version)
|
||||
if existing is None:
|
||||
self.db.add(
|
||||
AgentAssetVersion(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
content=content,
|
||||
content_type="markdown",
|
||||
change_note=change_note,
|
||||
created_by=actor,
|
||||
)
|
||||
)
|
||||
return
|
||||
existing.content = content
|
||||
existing.change_note = change_note
|
||||
existing.created_by = actor
|
||||
self.db.add(existing)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"id": asset.id,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"description": asset.description,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"config_json": asset.config_json or {},
|
||||
}
|
||||
@@ -40,7 +40,20 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
attachments=attachments,
|
||||
)
|
||||
recognition_summary = self._build_recognition_summary(attachments)
|
||||
fields = self._extract_manifest_fields(manifest)
|
||||
ocr_raw_fields = self._build_ocr_raw_fields(attachments)
|
||||
hermes_normalized_fields = self._build_hermes_normalized_fields(
|
||||
fields,
|
||||
field_values,
|
||||
source_map,
|
||||
)
|
||||
required_keys = self._extract_execution_field_keys(manifest)
|
||||
executor_input_fields = self._build_executor_input_fields(
|
||||
fields,
|
||||
field_values,
|
||||
source_map,
|
||||
required_keys,
|
||||
)
|
||||
missing_fields = self._build_missing_fields(
|
||||
manifest,
|
||||
field_values=field_values,
|
||||
@@ -67,6 +80,9 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
normalized_fields=field_values,
|
||||
attachments=attachments,
|
||||
recognized_fields=recognized_fields,
|
||||
ocr_raw_fields=ocr_raw_fields,
|
||||
hermes_normalized_fields=hermes_normalized_fields,
|
||||
executor_input_fields=executor_input_fields,
|
||||
missing_fields=missing_fields,
|
||||
recognition_summary=recognition_summary,
|
||||
created_at=datetime.now(UTC),
|
||||
@@ -108,6 +124,9 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {},
|
||||
attachments=attachments,
|
||||
recognized_fields=recognized_fields,
|
||||
ocr_raw_fields=ocr_raw_fields,
|
||||
hermes_normalized_fields=hermes_normalized_fields,
|
||||
executor_input_fields=executor_input_fields,
|
||||
missing_fields=[],
|
||||
recognition_summary=recognition_summary,
|
||||
created_at=datetime.now(UTC),
|
||||
@@ -565,6 +584,108 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
if source_map.get(key)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_ocr_raw_fields(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for attachment_index, attachment in enumerate(attachments):
|
||||
attachment_name = str(attachment.get("name") or f"attachment-{attachment_index + 1}")
|
||||
for field_index, field in enumerate(list(attachment.get("document_fields") or [])):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
value = field.get("value")
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
key = str(field.get("key") or f"field_{field_index + 1}").strip()
|
||||
label = str(field.get("label") or key).strip()
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"source": "ocr",
|
||||
"source_label": "OCR结构字段",
|
||||
"attachment_name": attachment_name,
|
||||
}
|
||||
)
|
||||
for key, label in (("summary", "单据摘要"), ("ocr_text", "OCR原文")):
|
||||
value = attachment.get(key)
|
||||
if AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"source": "ocr",
|
||||
"source_label": "OCR文本",
|
||||
"attachment_name": attachment_name,
|
||||
}
|
||||
)
|
||||
return rows[:80]
|
||||
|
||||
@staticmethod
|
||||
def _build_hermes_normalized_fields(
|
||||
fields: list[dict[str, str]],
|
||||
values: dict[str, Any],
|
||||
source_map: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
labels = {field["key"]: field["label"] for field in fields}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key, value in values.items():
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
source = source_map.get(key, "")
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": labels.get(key, key),
|
||||
"value": value,
|
||||
"source": source,
|
||||
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _build_executor_input_fields(
|
||||
fields: list[dict[str, str]],
|
||||
values: dict[str, Any],
|
||||
source_map: dict[str, str],
|
||||
required_keys: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
labels = {field["key"]: field["label"] for field in fields}
|
||||
required_set = set(required_keys or [])
|
||||
ordered_keys = [*required_keys]
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
if key not in ordered_keys and key in values:
|
||||
ordered_keys.append(key)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key in ordered_keys:
|
||||
value = values.get(key)
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
source = source_map.get(key, "")
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": labels.get(key, key),
|
||||
"value": value,
|
||||
"source": source,
|
||||
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
|
||||
"required": key in required_set,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _field_source_label(source: str) -> str:
|
||||
return {
|
||||
"manual": "用户输入",
|
||||
"ocr": "OCR结构字段",
|
||||
"inferred": "文本推断",
|
||||
"model_refined": "Hermes规范化",
|
||||
}.get(str(source or "").strip(), "未标注来源")
|
||||
|
||||
@staticmethod
|
||||
def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
|
||||
@@ -28,7 +28,9 @@ from app.schemas.agent_asset import (
|
||||
)
|
||||
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
|
||||
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
|
||||
from app.services.agent_asset_risk_rule_feedback import AgentAssetRiskRuleFeedbackMixin
|
||||
from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin
|
||||
from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublishMixin
|
||||
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
@@ -47,6 +49,8 @@ class AgentAssetService(
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
AgentAssetRiskRulePublishMixin,
|
||||
AgentAssetRiskRuleFeedbackMixin,
|
||||
AgentAssetRiskRuleTestingMixin,
|
||||
AgentAssetRiskRuleSimulationMixin,
|
||||
AgentAssetTimelineMixin,
|
||||
|
||||
@@ -126,14 +126,31 @@ class AgentFoundationAssetSeedMixin:
|
||||
[
|
||||
"---",
|
||||
"name: risk-rule-discovery",
|
||||
"description: 用于根据风险观察反馈生成候选规则,不直接上线。",
|
||||
"description: 兼容别名。用于归集申请和报销事实中的潜在线索,不生成规则。",
|
||||
"---",
|
||||
"",
|
||||
"# 风险规则候选发现",
|
||||
"# 风险线索归集",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
"从风险观察、人工反馈和误报复盘中生成带证据、来源和置信度的候选规则。",
|
||||
"从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。",
|
||||
],
|
||||
)
|
||||
|
||||
def _risk_clue_collector_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown(
|
||||
"risk-clue-collector",
|
||||
[
|
||||
"---",
|
||||
"name: risk-clue-collector",
|
||||
"description: 用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。",
|
||||
"---",
|
||||
"",
|
||||
"# 风险线索归集",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
"从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -370,6 +387,15 @@ class AgentFoundationAssetSeedMixin:
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -452,6 +478,10 @@ class AgentFoundationAssetSeedMixin:
|
||||
]
|
||||
)
|
||||
|
||||
self.db.flush()
|
||||
self._upsert_runtime_digital_employee_tasks(
|
||||
set(self.db.scalars(select(AgentAsset.code)).all())
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||
@@ -615,22 +645,6 @@ class AgentFoundationAssetSeedMixin:
|
||||
change_note="初始化整理公司财务知识制度能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=risk_graph_scan_task,
|
||||
version="v1.0.0",
|
||||
content=self._financial_risk_graph_scan_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化财务风险图谱巡检能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=employee_profile_scan_task,
|
||||
version="v1.0.0",
|
||||
content=self._employee_behavior_profile_scan_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化员工行为画像巡检能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -614,6 +614,15 @@ class AgentFoundationAssetTopUpMixin:
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
}
|
||||
|
||||
if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes:
|
||||
@@ -669,6 +678,15 @@ class AgentFoundationAssetTopUpMixin:
|
||||
"folder": existing_config.get("folder") or "财务制度",
|
||||
"changed_only": existing_config.get("changed_only", True),
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
**schedule_config,
|
||||
}
|
||||
self.db.add(asset)
|
||||
|
||||
@@ -96,6 +96,32 @@ DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile
|
||||
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE = "task.hermes.risk_rule_discovery"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE = "task.hermes.finance_policy_clause_extract"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignment"
|
||||
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize"
|
||||
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE = "task.hermes.false_positive_sample_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE = "task.hermes.risk_feedback_sample_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE = "task.hermes.multi_evidence_consistency_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE = "task.hermes.travel_spatiotemporal_consistency_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE = "task.hermes.budget_overrun_precontrol_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE = "task.hermes.supplier_abnormal_relation_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE = "task.hermes.risk_algorithm_replay_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE = "task.hermes.policy_gap_rule_optimization"
|
||||
|
||||
DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
|
||||
"task.hermes.daily_risk_scan",
|
||||
"task.hermes.weekly_ar_summary",
|
||||
@@ -107,8 +133,21 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE: "升级",
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE: "升级",
|
||||
}
|
||||
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||
|
||||
@@ -11,16 +11,126 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_foundation_constants import (
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
|
||||
)
|
||||
|
||||
|
||||
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
|
||||
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
||||
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
||||
)
|
||||
|
||||
|
||||
class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
|
||||
return (
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||
name="制度条款结构化抽取",
|
||||
description="按计划从财务制度和报销政策中抽取适用范围、限制条件、金额标准、审批要求和证据字段。",
|
||||
scenario_json=["schedule", "knowledge", "policy_clause", "ontology"],
|
||||
owner="财务制度管理组",
|
||||
cron="15 3 * * *",
|
||||
skill_category="整理",
|
||||
skill_name="finance-policy-clause-extractor",
|
||||
output_format="policy_clause_structuring_report",
|
||||
input_sources=["finance_policies", "knowledge_documents", "ontology_parse_logs"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
||||
name="报销政策口径对齐",
|
||||
description="对齐不同制度、规则中心和知识库中的报销口径,发现同义、冲突、缺失和过期条款。",
|
||||
scenario_json=["schedule", "knowledge", "expense_policy", "rule_center"],
|
||||
owner="财务制度管理组",
|
||||
cron="30 3 * * *",
|
||||
skill_category="整理",
|
||||
skill_name="expense-policy-alignment",
|
||||
output_format="policy_alignment_report",
|
||||
input_sources=["finance_policies", "risk_rules", "knowledge_items"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
|
||||
name="规则命中样本整理",
|
||||
description="把外层智能体流程已经产生的规则命中、制度引用和历史样本整理为字段映射与复核材料,不新增、不改写、不发布规则。",
|
||||
scenario_json=["schedule", "rule_hit", "risk_rule", "policy_ref"],
|
||||
owner="风控与审计部",
|
||||
cron="45 3 * * 1",
|
||||
skill_category="整理",
|
||||
skill_name="rule-execution-case-organizer",
|
||||
output_format="rule_hit_sample_pack",
|
||||
input_sources=["approved_risk_rules", "policy_refs", "rule_hits"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
||||
name="部门费用基线沉淀",
|
||||
description="按部门、费用类型和时间窗口沉淀费用基线,为预算柔性控制和同类对比提供长期参照。",
|
||||
scenario_json=["schedule", "department", "baseline", "expense"],
|
||||
owner="风控与审计部",
|
||||
cron="45 8 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="department-expense-baseline-accumulator",
|
||||
output_format="department_expense_baseline_snapshot",
|
||||
input_sources=["expense_claims", "expense_items", "profile_baselines"],
|
||||
execution_strategy="reuse_employee_profile_baseline",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
|
||||
name="供应商风险画像沉淀",
|
||||
description="沉淀供应商、商户、酒店和收款方的费用频次、金额分布、异常关系和历史风险反馈。",
|
||||
scenario_json=["schedule", "supplier", "baseline", "risk_graph"],
|
||||
owner="风控与审计部",
|
||||
cron="0 8 * * 2",
|
||||
skill_category="积累",
|
||||
skill_name="supplier-risk-profile-accumulator",
|
||||
output_format="supplier_risk_profile_snapshot",
|
||||
input_sources=["expense_claims", "invoice_entities", "risk_observations"],
|
||||
execution_strategy="reuse_profile_baseline",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
||||
name="历史误报样本沉淀",
|
||||
description="归集被人工标记为误报、忽略或撤销的风险观察,形成算法回放和人工复核校准样本。",
|
||||
scenario_json=["schedule", "false_positive", "feedback", "replay"],
|
||||
owner="风控与审计部",
|
||||
cron="20 10 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="false-positive-sample-accumulator",
|
||||
output_format="false_positive_sample_pool",
|
||||
input_sources=["risk_observations", "risk_observation_feedback"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
|
||||
name="风险观察反馈样本沉淀",
|
||||
description="归集确认、补件、升级、改写和人工复核反馈,形成风险观察反馈样本池。",
|
||||
scenario_json=["schedule", "feedback", "risk_observation", "sample_pool"],
|
||||
owner="风控与审计部",
|
||||
cron="40 10 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="risk-feedback-sample-accumulator",
|
||||
output_format="risk_feedback_sample_pool",
|
||||
input_sources=["risk_observations", "risk_observation_feedback", "agent_runs"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
{
|
||||
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||
"name": "财务风险图谱巡检",
|
||||
@@ -43,6 +153,15 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
],
|
||||
"output_format": "risk_observation_report",
|
||||
"writes_risk_observations": True,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -53,7 +172,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
"owner": "风控与审计部",
|
||||
"reviewer": "顾承宇",
|
||||
"cron": "30 8 * * 1",
|
||||
"skill_category": "评估",
|
||||
"skill_category": "积累",
|
||||
"markdown": self._employee_behavior_profile_scan_skill_markdown,
|
||||
"change_note": "初始化员工行为画像巡检能力。",
|
||||
"config": {
|
||||
@@ -66,30 +185,199 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
],
|
||||
"output_format": "employee_behavior_profile_snapshot",
|
||||
"writes_profile_snapshots": True,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"profile_snapshots",
|
||||
"baseline_metrics",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
},
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
|
||||
name="单据多凭证一致性评估",
|
||||
description="比对报销单、费用明细、发票、流水、合同和事前申请之间的金额、数量、主体和时间字段。",
|
||||
scenario_json=["schedule", "expense", "multi_evidence", "risk_observation"],
|
||||
owner="风控与审计部",
|
||||
cron="15 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="multi-evidence-consistency-evaluator",
|
||||
output_format="multi_evidence_consistency_report",
|
||||
input_sources=["expense_claims", "expense_items", "invoices", "attachments"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
|
||||
name="差旅时空一致性评估",
|
||||
description="评估差旅发生时间、提交时间、票据地点、消费地点、行程轨迹和开票地点是否一致。",
|
||||
scenario_json=["schedule", "travel", "spatiotemporal", "risk_observation"],
|
||||
owner="风控与审计部",
|
||||
cron="30 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="travel-spatiotemporal-consistency-evaluator",
|
||||
output_format="spatiotemporal_consistency_report",
|
||||
input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
|
||||
name="预算占用与超标预警",
|
||||
description="评估预算占用、费用标准、历史基线和柔性控制边界,输出提交前或审批前预警建议。",
|
||||
scenario_json=["schedule", "budget", "expense", "precontrol"],
|
||||
owner="预算管理组",
|
||||
cron="45 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="budget-overrun-precontrol-evaluator",
|
||||
output_format="budget_precontrol_warning_report",
|
||||
input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
|
||||
name="供应商异常关系评估",
|
||||
description="识别员工、部门、供应商、票据和报销单之间的异常聚集、重复关系和跨部门集中风险。",
|
||||
scenario_json=["schedule", "supplier", "risk_graph", "relationship"],
|
||||
owner="风控与审计部",
|
||||
cron="0 9 * * 2",
|
||||
skill_category="评估",
|
||||
skill_name="supplier-abnormal-relation-evaluator",
|
||||
output_format="supplier_abnormal_relation_report",
|
||||
input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
{
|
||||
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||
"name": "风险规则候选发现",
|
||||
"description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线。",
|
||||
"scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"],
|
||||
"name": "风险线索归集",
|
||||
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。",
|
||||
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
|
||||
"owner": "风控与审计部",
|
||||
"reviewer": "顾承宇",
|
||||
"cron": "0 10 * * 1",
|
||||
"skill_category": "升级",
|
||||
"markdown": self._risk_rule_discovery_skill_markdown,
|
||||
"change_note": "初始化风险规则候选发现能力。",
|
||||
"markdown": self._risk_clue_collector_skill_markdown,
|
||||
"change_note": "初始化风险线索归集能力。",
|
||||
"config": {
|
||||
"skill_name": "risk-rule-discovery",
|
||||
"task_type": "risk_clue_collect",
|
||||
"skill_name": "risk-clue-collector",
|
||||
"input_sources": [
|
||||
"risk_observations",
|
||||
"expense_applications",
|
||||
"expense_claims",
|
||||
"rule_hits",
|
||||
"risk_observation_feedback",
|
||||
"algorithm_replay_sets",
|
||||
],
|
||||
"output_format": "candidate_risk_rules",
|
||||
"auto_publish": False,
|
||||
"output_format": "risk_clue_review_packet",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
"human_review_required": True,
|
||||
},
|
||||
},
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
|
||||
name="风险算法回放评测",
|
||||
description="复跑历史风险观察、反馈标签、本体版本和规则版本,评估算法升级前后的误报率和确认率。",
|
||||
scenario_json=["schedule", "algorithm_replay", "evaluation", "feedback"],
|
||||
owner="风控与审计部",
|
||||
cron="30 10 * * 1",
|
||||
skill_category="升级",
|
||||
skill_name="risk-algorithm-replay-evaluator",
|
||||
output_format="algorithm_replay_evaluation_report",
|
||||
input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
||||
name="制度引用缺口提示",
|
||||
description="整理申请、报销、规则命中和人工反馈中缺少制度引用的事实位置,提示人工补齐制度依据,不输出规则变更建议。",
|
||||
scenario_json=["schedule", "policy_reference", "evidence_gap", "human_review"],
|
||||
owner="财务制度管理组",
|
||||
cron="0 11 * * 1",
|
||||
skill_category="升级",
|
||||
skill_name="policy-reference-gap-hinter",
|
||||
output_format="policy_reference_gap_hint_report",
|
||||
input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
)
|
||||
|
||||
def _digital_employee_task_spec(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
name: str,
|
||||
description: str,
|
||||
scenario_json: list[str],
|
||||
owner: str,
|
||||
cron: str,
|
||||
skill_category: str,
|
||||
skill_name: str,
|
||||
output_format: str,
|
||||
input_sources: list[str],
|
||||
execution_strategy: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"scenario_json": scenario_json,
|
||||
"owner": owner,
|
||||
"reviewer": "顾承宇",
|
||||
"cron": cron,
|
||||
"skill_category": skill_category,
|
||||
"markdown": lambda: self._generic_digital_employee_skill_markdown(
|
||||
skill_name=skill_name,
|
||||
title=name,
|
||||
description=description,
|
||||
),
|
||||
"change_note": f"初始化{name}能力。",
|
||||
"config": {
|
||||
"skill_name": skill_name,
|
||||
"input_sources": input_sources,
|
||||
"output_format": output_format,
|
||||
"writes_work_record": True,
|
||||
"execution_strategy": execution_strategy,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
}
|
||||
|
||||
def _generic_digital_employee_skill_markdown(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
title: str,
|
||||
description: str,
|
||||
) -> str:
|
||||
return self._read_domain_skill_markdown(
|
||||
skill_name,
|
||||
[
|
||||
"---",
|
||||
f"name: {skill_name}",
|
||||
f"description: {description}",
|
||||
"---",
|
||||
"",
|
||||
f"# {title}",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
description,
|
||||
],
|
||||
)
|
||||
|
||||
def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None:
|
||||
@@ -146,6 +434,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
cron = str(spec["cron"])
|
||||
base = {
|
||||
**self._digital_employee_task_config(code, cron),
|
||||
"skill_category": str(spec["skill_category"]),
|
||||
"schedule": cron,
|
||||
"cron_expression": cron,
|
||||
**dict(spec["config"]),
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.agent_foundation_constants import (
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
"all": "全部",
|
||||
"travel": "差旅费",
|
||||
"hotel": "住宿费",
|
||||
"transport": "交通费",
|
||||
@@ -158,6 +159,10 @@ class AgentFoundationRiskRuleMixin:
|
||||
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:
|
||||
@@ -170,6 +175,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
@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:
|
||||
|
||||
530
server/src/app/services/agent_traces.py
Normal file
530
server/src/app/services/agent_traces.py
Normal file
@@ -0,0 +1,530 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.base import Base
|
||||
from app.models.agent_conversation import AgentConversationMessage
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
|
||||
from app.schemas.agent_trace import (
|
||||
AgentConversationTraceRead,
|
||||
AgentTraceDetailRead,
|
||||
AgentTraceEventRead,
|
||||
AgentTraceListItem,
|
||||
)
|
||||
from app.schemas.orchestrator import ConversationMessageRead
|
||||
|
||||
logger = get_logger("app.services.agent_traces")
|
||||
|
||||
|
||||
class AgentTraceService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind(), tables=[AgentTraceEvent.__table__])
|
||||
|
||||
def record_event(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
stage: str,
|
||||
event_name: str,
|
||||
title: str,
|
||||
status: str = "succeeded",
|
||||
conversation_id: str | None = None,
|
||||
summary: str | None = None,
|
||||
input_json: dict[str, Any] | None = None,
|
||||
output_json: dict[str, Any] | None = None,
|
||||
error_message: str | None = None,
|
||||
started_at: datetime | None = None,
|
||||
finished_at: datetime | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> AgentTraceEventRead:
|
||||
self.ensure_storage_ready()
|
||||
started = _normalize_datetime(started_at) or datetime.now(UTC)
|
||||
finished = _normalize_datetime(finished_at)
|
||||
if finished is None and status != "running":
|
||||
finished = started
|
||||
|
||||
event = AgentTraceEvent(
|
||||
run_id=str(run_id or "").strip(),
|
||||
conversation_id=_optional_text(conversation_id),
|
||||
sequence=self._next_sequence(run_id),
|
||||
stage=str(stage or "orchestrator").strip() or "orchestrator",
|
||||
event_name=str(event_name or "").strip() or "event",
|
||||
title=str(title or event_name or "Trace event").strip(),
|
||||
summary=_optional_text(summary),
|
||||
status=str(status or "succeeded").strip() or "succeeded",
|
||||
input_json=_json_safe(input_json or {}),
|
||||
output_json=_json_safe(output_json or {}),
|
||||
error_message=_optional_text(error_message),
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_ms=_resolve_duration_ms(started, finished, duration_ms),
|
||||
)
|
||||
self.db.add(event)
|
||||
self.db.commit()
|
||||
self.db.refresh(event)
|
||||
return AgentTraceEventRead.model_validate(event)
|
||||
|
||||
def record_event_safe(self, **kwargs: Any) -> AgentTraceEventRead | None:
|
||||
try:
|
||||
return self.record_event(**kwargs)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.exception("Failed to record agent trace event run_id=%s", kwargs.get("run_id"))
|
||||
return None
|
||||
|
||||
def record_tool_event_safe(
|
||||
self,
|
||||
run_id: str,
|
||||
tool_type: str,
|
||||
tool_name: str,
|
||||
request_json: dict[str, Any],
|
||||
response_json: dict[str, Any],
|
||||
status: str,
|
||||
duration_ms: int,
|
||||
context_json: dict[str, Any],
|
||||
error_message: str | None = None,
|
||||
) -> AgentTraceEventRead | None:
|
||||
return self.record_event_safe(
|
||||
run_id=run_id,
|
||||
conversation_id=str(context_json.get("conversation_id") or "").strip() or None,
|
||||
stage="tool",
|
||||
event_name="tool_invoked",
|
||||
title=tool_name,
|
||||
status=status,
|
||||
summary=f"{tool_type} / {status}",
|
||||
input_json=request_json,
|
||||
output_json=response_json,
|
||||
error_message=error_message,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
def list_traces(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> list[AgentTraceListItem]:
|
||||
self.ensure_storage_ready()
|
||||
normalized_limit = max(1, min(int(limit or 30), 100))
|
||||
fetch_limit = normalized_limit * 4 if keyword else normalized_limit
|
||||
stmt = select(AgentRun)
|
||||
if agent:
|
||||
stmt = stmt.where(AgentRun.agent == agent)
|
||||
if status:
|
||||
stmt = stmt.where(AgentRun.status == status)
|
||||
if source:
|
||||
stmt = stmt.where(AgentRun.source == source)
|
||||
if conversation_id:
|
||||
run_ids = self._conversation_run_ids(conversation_id)
|
||||
if not run_ids:
|
||||
return []
|
||||
stmt = stmt.where(AgentRun.run_id.in_(run_ids))
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(fetch_limit)
|
||||
runs = list(self.db.scalars(stmt).all())
|
||||
event_counts = self._event_counts([run.run_id for run in runs])
|
||||
keyword_text = str(keyword or "").strip().lower()
|
||||
items = [self._build_trace_list_item(run, event_counts.get(run.run_id, 0)) for run in runs]
|
||||
if keyword_text:
|
||||
items = [item for item in items if self._matches_keyword(item, keyword_text)]
|
||||
return items[:normalized_limit]
|
||||
|
||||
def get_trace(self, run_id: str) -> AgentTraceDetailRead | None:
|
||||
self.ensure_storage_ready()
|
||||
normalized_run_id = str(run_id or "").strip()
|
||||
if not normalized_run_id:
|
||||
return None
|
||||
|
||||
run = self.db.scalar(select(AgentRun).where(AgentRun.run_id == normalized_run_id))
|
||||
if run is None:
|
||||
return None
|
||||
|
||||
db_events = list(
|
||||
self.db.scalars(
|
||||
select(AgentTraceEvent)
|
||||
.where(AgentTraceEvent.run_id == normalized_run_id)
|
||||
.order_by(AgentTraceEvent.sequence.asc(), AgentTraceEvent.started_at.asc())
|
||||
).all()
|
||||
)
|
||||
conversation_id = self._resolve_conversation_id(run, db_events)
|
||||
events = [AgentTraceEventRead.model_validate(event) for event in db_events]
|
||||
fallback_generated = False
|
||||
if not events:
|
||||
events = self._build_fallback_events(run, conversation_id)
|
||||
fallback_generated = True
|
||||
|
||||
return AgentTraceDetailRead(
|
||||
run=self._serialize_run(run),
|
||||
conversation_id=conversation_id,
|
||||
events=events,
|
||||
semantic_parse=self._serialize_semantic_parse(self._first_semantic_parse(run)),
|
||||
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
|
||||
conversation_messages=self._conversation_messages(conversation_id),
|
||||
fallback_generated=fallback_generated,
|
||||
)
|
||||
|
||||
def get_conversation_trace(self, conversation_id: str) -> AgentConversationTraceRead:
|
||||
normalized_conversation_id = str(conversation_id or "").strip()
|
||||
run_ids = self._conversation_run_ids(normalized_conversation_id)
|
||||
details = []
|
||||
for run_id in run_ids:
|
||||
detail = self.get_trace(run_id)
|
||||
if detail is not None:
|
||||
details.append(detail)
|
||||
return AgentConversationTraceRead(
|
||||
conversation_id=normalized_conversation_id,
|
||||
runs=details,
|
||||
)
|
||||
|
||||
def _next_sequence(self, run_id: str) -> int:
|
||||
current = self.db.scalar(
|
||||
select(func.max(AgentTraceEvent.sequence)).where(AgentTraceEvent.run_id == run_id)
|
||||
)
|
||||
return int(current or 0) + 1
|
||||
|
||||
def _event_counts(self, run_ids: list[str]) -> dict[str, int]:
|
||||
if not run_ids:
|
||||
return {}
|
||||
rows = self.db.execute(
|
||||
select(AgentTraceEvent.run_id, func.count(AgentTraceEvent.id))
|
||||
.where(AgentTraceEvent.run_id.in_(run_ids))
|
||||
.group_by(AgentTraceEvent.run_id)
|
||||
).all()
|
||||
return {str(run_id): int(count or 0) for run_id, count in rows}
|
||||
|
||||
def _build_trace_list_item(self, run: AgentRun, event_count: int) -> AgentTraceListItem:
|
||||
semantic_parse = self._first_semantic_parse(run)
|
||||
failed_tools = sum(1 for item in run.tool_calls if item.status == "failed")
|
||||
title = self._resolve_run_title(run, semantic_parse)
|
||||
finished_at = _normalize_datetime(run.finished_at)
|
||||
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
|
||||
return AgentTraceListItem(
|
||||
run_id=run.run_id,
|
||||
conversation_id=self._resolve_conversation_id(run, []),
|
||||
agent=run.agent,
|
||||
source=run.source,
|
||||
status=run.status,
|
||||
scenario=semantic_parse.scenario if semantic_parse is not None else None,
|
||||
intent=semantic_parse.intent if semantic_parse is not None else None,
|
||||
title=title,
|
||||
summary=run.result_summary,
|
||||
event_count=event_count,
|
||||
tool_call_count=len(run.tool_calls),
|
||||
failed_tool_call_count=failed_tools,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=_resolve_duration_ms(started_at, finished_at, None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _matches_keyword(item: AgentTraceListItem, keyword: str) -> bool:
|
||||
corpus = " ".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
item.run_id,
|
||||
item.conversation_id,
|
||||
item.agent,
|
||||
item.source,
|
||||
item.status,
|
||||
item.scenario,
|
||||
item.intent,
|
||||
item.title,
|
||||
item.summary,
|
||||
)
|
||||
).lower()
|
||||
return keyword in corpus
|
||||
|
||||
def _resolve_conversation_id(
|
||||
self,
|
||||
run: AgentRun,
|
||||
events: list[AgentTraceEvent],
|
||||
) -> str | None:
|
||||
route_value = (run.route_json or {}).get("conversation_id")
|
||||
if route_value:
|
||||
return str(route_value).strip() or None
|
||||
for event in events:
|
||||
if event.conversation_id:
|
||||
return str(event.conversation_id).strip() or None
|
||||
message = self.db.scalar(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.run_id == run.run_id)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
)
|
||||
return str(message.conversation_id).strip() if message is not None else None
|
||||
|
||||
def _conversation_run_ids(self, conversation_id: str) -> list[str]:
|
||||
normalized = str(conversation_id or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
self.ensure_storage_ready()
|
||||
run_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def append_run_id(value: str | None) -> None:
|
||||
run_id = str(value or "").strip()
|
||||
if run_id and run_id not in seen:
|
||||
seen.add(run_id)
|
||||
run_ids.append(run_id)
|
||||
|
||||
messages = list(
|
||||
self.db.scalars(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.conversation_id == normalized)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
).all()
|
||||
)
|
||||
for message in messages:
|
||||
append_run_id(message.run_id)
|
||||
|
||||
trace_event_run_ids = list(
|
||||
self.db.scalars(
|
||||
select(AgentTraceEvent.run_id)
|
||||
.where(AgentTraceEvent.conversation_id == normalized)
|
||||
.order_by(AgentTraceEvent.created_at.asc(), AgentTraceEvent.sequence.asc())
|
||||
).all()
|
||||
)
|
||||
for run_id in trace_event_run_ids:
|
||||
append_run_id(run_id)
|
||||
|
||||
recent_runs = list(
|
||||
self.db.scalars(
|
||||
select(AgentRun).order_by(AgentRun.started_at.desc()).limit(500)
|
||||
).all()
|
||||
)
|
||||
for run in reversed(recent_runs):
|
||||
if str((run.route_json or {}).get("conversation_id") or "").strip() == normalized:
|
||||
append_run_id(run.run_id)
|
||||
return run_ids
|
||||
|
||||
def _conversation_messages(self, conversation_id: str | None) -> list[ConversationMessageRead]:
|
||||
if not conversation_id:
|
||||
return []
|
||||
messages = list(
|
||||
self.db.scalars(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
.limit(100)
|
||||
).all()
|
||||
)
|
||||
return [
|
||||
ConversationMessageRead(
|
||||
id=item.id,
|
||||
role=item.role,
|
||||
content=item.content,
|
||||
run_id=item.run_id,
|
||||
message_json=item.message_json or {},
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in messages
|
||||
]
|
||||
|
||||
def _build_fallback_events(
|
||||
self,
|
||||
run: AgentRun,
|
||||
conversation_id: str | None,
|
||||
) -> list[AgentTraceEventRead]:
|
||||
events: list[AgentTraceEventRead] = []
|
||||
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
|
||||
semantic_parse = self._first_semantic_parse(run)
|
||||
|
||||
def append_event(
|
||||
*,
|
||||
stage: str,
|
||||
event_name: str,
|
||||
title: str,
|
||||
status: str,
|
||||
summary: str | None,
|
||||
started: datetime,
|
||||
finished: datetime | None = None,
|
||||
input_json: dict[str, Any] | None = None,
|
||||
output_json: dict[str, Any] | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
sequence = len(events) + 1
|
||||
resolved_finished = finished or started
|
||||
events.append(
|
||||
AgentTraceEventRead(
|
||||
id=f"fallback-{run.run_id}-{sequence}",
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
sequence=sequence,
|
||||
stage=stage,
|
||||
event_name=event_name,
|
||||
title=title,
|
||||
summary=summary,
|
||||
status=status,
|
||||
input_json=_json_safe(input_json or {}),
|
||||
output_json=_json_safe(output_json or {}),
|
||||
error_message=error_message,
|
||||
started_at=started,
|
||||
finished_at=resolved_finished,
|
||||
duration_ms=_resolve_duration_ms(started, resolved_finished, None),
|
||||
created_at=started,
|
||||
)
|
||||
)
|
||||
|
||||
append_event(
|
||||
stage="orchestrator",
|
||||
event_name="run_created",
|
||||
title="运行记录",
|
||||
status="succeeded",
|
||||
summary="由历史 AgentRun 合成的 trace 起点。",
|
||||
started=started_at,
|
||||
output_json={"agent": run.agent, "source": run.source, "status": run.status},
|
||||
)
|
||||
if semantic_parse is not None:
|
||||
append_event(
|
||||
stage="semantic",
|
||||
event_name="semantic_parsed",
|
||||
title="语义解析",
|
||||
status="succeeded",
|
||||
summary=f"{semantic_parse.scenario} / {semantic_parse.intent}",
|
||||
started=_normalize_datetime(semantic_parse.created_at) or started_at,
|
||||
input_json={"raw_query": semantic_parse.raw_query},
|
||||
output_json=self._semantic_parse_payload(semantic_parse),
|
||||
)
|
||||
if run.route_json:
|
||||
append_event(
|
||||
stage="route",
|
||||
event_name="route_resolved",
|
||||
title="路由上下文",
|
||||
status="succeeded",
|
||||
summary=str(run.route_json.get("route_reason") or run.route_json.get("stage") or "已记录路由信息"),
|
||||
started=started_at,
|
||||
output_json=run.route_json,
|
||||
)
|
||||
for tool_call in run.tool_calls:
|
||||
append_event(
|
||||
stage="tool",
|
||||
event_name="tool_invoked",
|
||||
title=tool_call.tool_name,
|
||||
status=tool_call.status,
|
||||
summary=f"{tool_call.tool_type} / {tool_call.status}",
|
||||
started=_normalize_datetime(tool_call.created_at) or started_at,
|
||||
finished=_normalize_datetime(tool_call.created_at) or started_at,
|
||||
input_json=tool_call.request_json,
|
||||
output_json=tool_call.response_json,
|
||||
error_message=tool_call.error_message,
|
||||
)
|
||||
append_event(
|
||||
stage="response",
|
||||
event_name="response_built" if run.status != "failed" else "failed",
|
||||
title="最终结果",
|
||||
status=run.status,
|
||||
summary=run.result_summary or run.error_message,
|
||||
started=_normalize_datetime(run.finished_at) or started_at,
|
||||
output_json={"result_summary": run.result_summary},
|
||||
error_message=run.error_message,
|
||||
)
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _resolve_run_title(run: AgentRun, semantic_parse: SemanticParseLog | None) -> str:
|
||||
if semantic_parse is not None:
|
||||
return f"{semantic_parse.scenario} / {semantic_parse.intent}"
|
||||
route_json = run.route_json or {}
|
||||
return str(route_json.get("task_name") or route_json.get("selected_agent") or run.agent)
|
||||
|
||||
@staticmethod
|
||||
def _first_semantic_parse(run: AgentRun) -> SemanticParseLog | None:
|
||||
return run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_semantic_parse(item: SemanticParseLog | None) -> SemanticParseRead | None:
|
||||
return SemanticParseRead.model_validate(item) if item is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
||||
semantic_parse = AgentTraceService._first_semantic_parse(run)
|
||||
return AgentRunRead(
|
||||
id=run.id,
|
||||
run_id=run.run_id,
|
||||
agent=run.agent,
|
||||
source=run.source,
|
||||
user_id=run.user_id,
|
||||
task_id=run.task_id,
|
||||
ontology_json=run.ontology_json,
|
||||
route_json=run.route_json,
|
||||
permission_level=run.permission_level,
|
||||
status=run.status,
|
||||
result_summary=run.result_summary,
|
||||
error_message=run.error_message,
|
||||
started_at=run.started_at,
|
||||
finished_at=run.finished_at,
|
||||
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
|
||||
semantic_parse=SemanticParseRead.model_validate(semantic_parse)
|
||||
if semantic_parse is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _semantic_parse_payload(item: SemanticParseLog) -> dict[str, Any]:
|
||||
return {
|
||||
"scenario": item.scenario,
|
||||
"intent": item.intent,
|
||||
"confidence": item.confidence,
|
||||
"entities": item.entities_json,
|
||||
"time_range": item.time_range_json,
|
||||
"metrics": item.metrics_json,
|
||||
"constraints": item.constraints_json,
|
||||
"risk_flags": item.risk_flags_json,
|
||||
"permission": item.permission_json,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_duration_ms(
|
||||
started_at: datetime | None,
|
||||
finished_at: datetime | None,
|
||||
duration_ms: int | None,
|
||||
) -> int:
|
||||
if duration_ms is not None:
|
||||
return max(0, int(duration_ms or 0))
|
||||
if started_at is None or finished_at is None:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||
except TypeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json_safe(value.model_dump())
|
||||
return str(value)
|
||||
231
server/src/app/services/application_system_estimate.py
Normal file
231
server/src/app/services/application_system_estimate.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
LOCATION_BANDS = {
|
||||
"premium": ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津"),
|
||||
"remote": ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "香港", "澳门", "台湾", "海外", "国外"),
|
||||
"coastal": ("上海", "广州", "深圳", "厦门", "福州", "青岛", "大连", "宁波", "舟山", "海口", "三亚", "天津"),
|
||||
}
|
||||
|
||||
TRANSPORT_PRICE_BASE = {
|
||||
"火车": {"default": Decimal("360"), "premium": Decimal("520"), "remote": Decimal("900"), "coastal": Decimal("520")},
|
||||
"飞机": {"default": Decimal("850"), "premium": Decimal("1100"), "remote": Decimal("1800"), "coastal": Decimal("1050")},
|
||||
"轮船": {"default": Decimal("320"), "premium": Decimal("480"), "remote": Decimal("680"), "coastal": Decimal("520")},
|
||||
}
|
||||
|
||||
LODGING_DAILY_BASE = {
|
||||
"default": Decimal("420"),
|
||||
"premium": Decimal("600"),
|
||||
"remote": Decimal("520"),
|
||||
"coastal": Decimal("500"),
|
||||
}
|
||||
|
||||
ALLOWANCE_DAILY_BASE = {
|
||||
"default": Decimal("100"),
|
||||
"premium": Decimal("120"),
|
||||
"remote": Decimal("120"),
|
||||
"coastal": Decimal("110"),
|
||||
}
|
||||
|
||||
|
||||
def parse_application_days(days_text: str) -> int:
|
||||
match = re.search(r"\d+", str(days_text or ""))
|
||||
if not match:
|
||||
return 1
|
||||
return max(1, int(match.group(0)))
|
||||
|
||||
|
||||
def parse_application_money(value: object) -> Decimal:
|
||||
normalized = re.sub(r"[^\d.\-]", "", str(value or "").replace(",", ""))
|
||||
if not normalized:
|
||||
return Decimal("0")
|
||||
try:
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def format_application_money(value: Decimal | int | float | str) -> str:
|
||||
amount = parse_application_money(value) if not isinstance(value, Decimal) else value
|
||||
quantized = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if quantized == quantized.to_integral():
|
||||
return f"{int(quantized):,}"
|
||||
return f"{quantized:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def normalize_application_transport_mode(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if re.search(r"飞机|机票|航班|乘机|坐飞机", text):
|
||||
return "飞机"
|
||||
if re.search(r"轮船|船票|客轮|渡轮|邮轮|坐船", text):
|
||||
return "轮船"
|
||||
if re.search(r"火车|高铁|动车|铁路|列车", text):
|
||||
return "火车"
|
||||
return text if text in TRANSPORT_PRICE_BASE else ""
|
||||
|
||||
|
||||
def resolve_application_location_band(location: str) -> str:
|
||||
text = str(location or "").strip()
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["remote"]):
|
||||
return "remote"
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["premium"]):
|
||||
return "premium"
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["coastal"]):
|
||||
return "coastal"
|
||||
return "default"
|
||||
|
||||
|
||||
def _round_to_ten(value: Decimal) -> Decimal:
|
||||
return (value / Decimal("10")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * Decimal("10")
|
||||
|
||||
|
||||
def parse_application_start_date(time_text: object) -> str:
|
||||
match = re.search(r"(20\d{2})[年\-/.](\d{1,2})[月\-/.](\d{1,2})", str(time_text or ""))
|
||||
if not match:
|
||||
return ""
|
||||
try:
|
||||
return date(int(match.group(1)), int(match.group(2)), int(match.group(3))).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_ticket_price_factor(query_date: str) -> Decimal:
|
||||
if not query_date:
|
||||
return Decimal("1.00")
|
||||
try:
|
||||
parsed = date.fromisoformat(query_date)
|
||||
except ValueError:
|
||||
return Decimal("1.00")
|
||||
|
||||
factor = Decimal("1.00")
|
||||
if parsed.weekday() == 0:
|
||||
factor += Decimal("0.04")
|
||||
if parsed.weekday() in (4, 6):
|
||||
factor += Decimal("0.08")
|
||||
if parsed.month in {1, 2, 7, 8, 10}:
|
||||
factor += Decimal("0.06")
|
||||
|
||||
jitter = (parsed.year + parsed.month * 13 + parsed.day * 7) % 7 - 3
|
||||
factor += Decimal(jitter) / Decimal("100")
|
||||
if factor < Decimal("0.88"):
|
||||
return Decimal("0.88")
|
||||
if factor > Decimal("1.22"):
|
||||
return Decimal("1.22")
|
||||
return factor
|
||||
|
||||
|
||||
def _resolve_mock_query_latency_ms(query_date: str, mode: str, location_band: str) -> int:
|
||||
try:
|
||||
parsed = date.fromisoformat(query_date) if query_date else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
seed = len(mode) * 43 + len(location_band) * 29
|
||||
if parsed:
|
||||
seed += parsed.year + parsed.month * 17 + parsed.day * 31
|
||||
return 360 + seed % 420
|
||||
|
||||
|
||||
def build_application_system_estimate(
|
||||
*,
|
||||
transport_mode: str,
|
||||
location: str,
|
||||
days_text: str,
|
||||
time_text: object = "",
|
||||
lodging_amount: object = None,
|
||||
allowance_amount: object = None,
|
||||
) -> dict[str, str]:
|
||||
mode = normalize_application_transport_mode(transport_mode)
|
||||
if not mode:
|
||||
return {}
|
||||
|
||||
days = parse_application_days(days_text)
|
||||
location_band = resolve_application_location_band(location)
|
||||
query_date = parse_application_start_date(time_text)
|
||||
price_factor = _resolve_ticket_price_factor(query_date)
|
||||
simulated_latency_ms = _resolve_mock_query_latency_ms(query_date, mode, location_band)
|
||||
transport_one_way = TRANSPORT_PRICE_BASE[mode].get(location_band, TRANSPORT_PRICE_BASE[mode]["default"])
|
||||
transport_amount = _round_to_ten(transport_one_way * Decimal("2") * price_factor)
|
||||
|
||||
lodging = parse_application_money(lodging_amount)
|
||||
allowance = parse_application_money(allowance_amount)
|
||||
lodging_daily = LODGING_DAILY_BASE.get(location_band, LODGING_DAILY_BASE["default"])
|
||||
allowance_daily = ALLOWANCE_DAILY_BASE.get(location_band, ALLOWANCE_DAILY_BASE["default"])
|
||||
if lodging <= 0:
|
||||
lodging = lodging_daily * days
|
||||
if allowance <= 0:
|
||||
allowance = allowance_daily * days
|
||||
|
||||
total_amount = transport_amount + lodging + allowance
|
||||
transport_display = format_application_money(transport_amount)
|
||||
lodging_display = format_application_money(lodging)
|
||||
allowance_display = format_application_money(allowance)
|
||||
total_display = format_application_money(total_amount)
|
||||
band_label = {
|
||||
"premium": "一线/高频城市",
|
||||
"remote": "远途地区",
|
||||
"coastal": "沿海城市",
|
||||
"default": "普通城市",
|
||||
}[location_band]
|
||||
query_label = query_date or "出行日期待确认"
|
||||
|
||||
return {
|
||||
"amount": f"{total_display}元",
|
||||
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
|
||||
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
|
||||
"transport_policy": (
|
||||
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
|
||||
f"(查询耗时 {simulated_latency_ms}ms),报销阶段按真实票据复核"
|
||||
),
|
||||
"policy_estimate": (
|
||||
f"交通 {transport_display}元(按 {query_label} 参考票价) + 住宿 {lodging_display}元"
|
||||
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
|
||||
),
|
||||
"matched_city": str(location or "").strip(),
|
||||
"transport_estimated_amount": f"{transport_display}元",
|
||||
"transport_estimate_date": query_date,
|
||||
"transport_query_latency_ms": str(simulated_latency_ms),
|
||||
"policy_total_amount": f"{total_display}元",
|
||||
"estimate_source": "mock_ticket_price_query_v1",
|
||||
"estimate_confidence": "mock",
|
||||
}
|
||||
|
||||
|
||||
def _is_pending_application_amount(value: str) -> bool:
|
||||
normalized = str(value or "").strip()
|
||||
return not normalized or normalized in {"待测算", "待补充", "未知"}
|
||||
|
||||
|
||||
def apply_application_system_estimate_to_facts(facts: dict[str, str]) -> None:
|
||||
estimate = build_application_system_estimate(
|
||||
transport_mode=str(facts.get("transport_mode") or ""),
|
||||
location=str(facts.get("matched_city") or facts.get("location") or ""),
|
||||
days_text=str(facts.get("days") or ""),
|
||||
time_text=facts.get("time") or "",
|
||||
lodging_amount=facts.get("hotel_amount") or None,
|
||||
allowance_amount=facts.get("allowance_amount") or None,
|
||||
)
|
||||
if not estimate:
|
||||
return
|
||||
|
||||
if _is_pending_application_amount(facts.get("amount", "")):
|
||||
facts["amount"] = estimate["amount"]
|
||||
|
||||
field_map = {
|
||||
"lodging_daily_cap": "lodging_daily_cap",
|
||||
"subsidy_daily_cap": "subsidy_daily_cap",
|
||||
"transport_policy": "transport_policy",
|
||||
"policy_estimate": "policy_estimate",
|
||||
"matched_city": "matched_city",
|
||||
"transport_estimated_amount": "transport_estimated_amount",
|
||||
"transport_estimate_date": "transport_estimate_date",
|
||||
"transport_query_latency_ms": "transport_query_latency_ms",
|
||||
"transport_estimate_source": "estimate_source",
|
||||
"transport_estimate_confidence": "estimate_confidence",
|
||||
"policy_total_amount": "policy_total_amount",
|
||||
}
|
||||
for target, source in field_map.items():
|
||||
if not str(facts.get(target) or "").strip() and estimate.get(source):
|
||||
facts[target] = estimate[source]
|
||||
@@ -81,6 +81,20 @@ class AuthService:
|
||||
session = UserSessionMetricService(self.db).start_session(user)
|
||||
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
|
||||
|
||||
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
|
||||
normalized = identifier.strip()
|
||||
if not normalized or not self.settings.setup_completed:
|
||||
return None
|
||||
|
||||
employee = self._find_employee_by_email(normalized)
|
||||
if employee is None:
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
employee = self._find_employee_by_email(normalized)
|
||||
if employee is None or employee.employment_status == "停用":
|
||||
return None
|
||||
|
||||
return self._serialize_user(self._build_employee_user(employee))
|
||||
|
||||
def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None:
|
||||
record = SettingsService(self.db).verify_admin_login(identifier, password)
|
||||
if record is None:
|
||||
@@ -114,17 +128,7 @@ class AuthService:
|
||||
return None
|
||||
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
employee = self._find_employee_by_email(identifier)
|
||||
|
||||
if employee is None or not employee.password_hash:
|
||||
return None
|
||||
@@ -136,6 +140,21 @@ class AuthService:
|
||||
if not verify_password(password, employee.password_hash):
|
||||
return None
|
||||
|
||||
return self._build_employee_user(employee)
|
||||
|
||||
def _find_employee_by_email(self, identifier: str) -> Employee | None:
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def _build_employee_user(self, employee: Employee) -> AuthenticatedUser:
|
||||
sorted_roles = sorted(
|
||||
list(employee.roles),
|
||||
key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name),
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.budget_types import (
|
||||
SUPPORTED_BUDGET_SUBJECT_CODES,
|
||||
)
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
|
||||
|
||||
@@ -604,7 +605,12 @@ class BudgetSupportMixin:
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
payload.update(extra or {})
|
||||
return payload
|
||||
return enrich_risk_flag_semantics(
|
||||
payload,
|
||||
risk_domain="budget",
|
||||
visibility_scope="budget_manager",
|
||||
actionability="budget_governance",
|
||||
)
|
||||
|
||||
def _build_operation_flag(
|
||||
self,
|
||||
|
||||
637
server/src/app/services/digital_employee_dashboard.py
Normal file
637
server/src/app/services/digital_employee_dashboard.py
Normal file
@@ -0,0 +1,637 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.agent_enums import AgentName, AgentRunSource
|
||||
from app.db.base import Base
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||
|
||||
SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
|
||||
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
|
||||
RUNNING_STATUSES = {"running", "pending"}
|
||||
|
||||
TASK_CODE_TO_TYPE = {
|
||||
"task.hermes.global_risk_scan": "global_risk_scan",
|
||||
"task.hermes.employee_behavior_profile_scan": "employee_behavior_profile_scan",
|
||||
"task.hermes.risk_rule_discovery": "risk_clue_collect",
|
||||
"task.hermes.finance_policy_knowledge_organize": "finance_policy_knowledge_organize",
|
||||
"task.hermes.finance_policy_clause_extract": "finance_policy_clause_extract",
|
||||
"task.hermes.expense_policy_alignment": "expense_policy_alignment",
|
||||
"task.hermes.risk_rule_template_organize": "risk_rule_template_organize",
|
||||
"task.hermes.department_expense_baseline_accumulate": "department_expense_baseline_accumulate",
|
||||
"task.hermes.supplier_risk_profile_accumulate": "supplier_risk_profile_accumulate",
|
||||
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
|
||||
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
|
||||
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
|
||||
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
|
||||
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
|
||||
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
|
||||
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
|
||||
"task.hermes.policy_gap_rule_optimization": "policy_gap_rule_optimization",
|
||||
}
|
||||
|
||||
TASK_SPECS: dict[str, dict[str, str]] = {
|
||||
"global_risk_scan": {
|
||||
"label": "财务风险图谱巡检",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"employee_behavior_profile_scan": {
|
||||
"label": "员工行为画像巡检",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"risk_clue_collect": {
|
||||
"label": "风险线索归集",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
"finance_policy_knowledge_organize": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"knowledge_index_sync": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"llm_wiki_sync": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"llm_wiki_rule_formation": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"finance_policy_clause_extract": {
|
||||
"label": "制度条款结构化抽取",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"expense_policy_alignment": {
|
||||
"label": "报销政策口径对齐",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"risk_rule_template_organize": {
|
||||
"label": "规则命中样本整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"department_expense_baseline_accumulate": {
|
||||
"label": "部门费用基线沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"supplier_risk_profile_accumulate": {
|
||||
"label": "供应商风险画像沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"false_positive_sample_accumulate": {
|
||||
"label": "历史误报样本沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"risk_feedback_sample_accumulate": {
|
||||
"label": "风险观察反馈样本沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"multi_evidence_consistency_evaluate": {
|
||||
"label": "多源证据一致性评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"travel_spatiotemporal_consistency_evaluate": {
|
||||
"label": "差旅时空一致性评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"budget_overrun_precontrol_evaluate": {
|
||||
"label": "预算超限预警评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"supplier_abnormal_relation_evaluate": {
|
||||
"label": "供应商异常关系评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"risk_algorithm_replay_evaluate": {
|
||||
"label": "风险算法回放升级",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
"policy_gap_rule_optimization": {
|
||||
"label": "制度缺口优化建议",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_SPECS = {
|
||||
"积累": {"color": "var(--chart-blue)", "description": "沉淀画像、基线和反馈样本"},
|
||||
"升级": {"color": "var(--chart-amber)", "description": "输出待复核线索和优化建议"},
|
||||
"整理": {"color": "var(--success)", "description": "整理制度、条款、知识和样本"},
|
||||
"评估": {"color": "var(--theme-primary)", "description": "评估异常、风险和一致性"},
|
||||
}
|
||||
|
||||
|
||||
class DigitalEmployeeDashboardService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(self, *, days: int = 7, limit: int = 300) -> DigitalEmployeeDashboardRead:
|
||||
window_days = max(1, min(int(days or 7), 30))
|
||||
window_limit = max(1, min(int(limit or 300), 1000))
|
||||
self._ensure_storage_ready()
|
||||
now = datetime.now(UTC)
|
||||
start = now - timedelta(days=window_days - 1)
|
||||
labels = self._date_labels(start.date(), window_days)
|
||||
|
||||
all_runs = self._fetch_runs(start=start, limit=window_limit)
|
||||
runs = [run for run in all_runs if self._is_digital_employee_run(run)]
|
||||
totals = self._build_totals(runs)
|
||||
|
||||
return DigitalEmployeeDashboardRead(
|
||||
window_days=window_days,
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(runs),
|
||||
totals=totals,
|
||||
daily_work=self._daily_work(labels, runs),
|
||||
task_distribution=self._task_distribution(runs),
|
||||
category_distribution=self._category_distribution(runs),
|
||||
recent_runs=self._recent_runs(runs),
|
||||
)
|
||||
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
|
||||
def _fetch_runs(self, *, start: datetime, limit: int) -> list[AgentRun]:
|
||||
stmt = (
|
||||
select(AgentRun)
|
||||
.options(selectinload(AgentRun.tool_calls))
|
||||
.where(
|
||||
AgentRun.started_at >= start,
|
||||
or_(
|
||||
AgentRun.agent == AgentName.HERMES.value,
|
||||
AgentRun.source == AgentRunSource.SCHEDULE.value,
|
||||
),
|
||||
)
|
||||
.order_by(AgentRun.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _build_totals(self, runs: list[AgentRun]) -> dict[str, Any]:
|
||||
metrics = self._sum_metrics(runs)
|
||||
success_runs = sum(1 for run in runs if self._is_success(run.status))
|
||||
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
|
||||
running_runs = sum(1 for run in runs if self._is_running(run.status))
|
||||
total_runs = len(runs)
|
||||
business_outputs = (
|
||||
metrics["risk_observations"]
|
||||
+ metrics["risk_clues"]
|
||||
+ metrics["profile_snapshots"]
|
||||
+ metrics["knowledge_documents"]
|
||||
)
|
||||
|
||||
return {
|
||||
"totalRuns": total_runs,
|
||||
"successRuns": success_runs,
|
||||
"failedRuns": failed_runs,
|
||||
"runningRuns": running_runs,
|
||||
"toolCalls": sum(len(run.tool_calls) for run in runs),
|
||||
"businessOutputs": business_outputs,
|
||||
"riskObservations": metrics["risk_observations"],
|
||||
"riskClues": metrics["risk_clues"],
|
||||
"profileSnapshots": metrics["profile_snapshots"],
|
||||
"knowledgeDocuments": metrics["knowledge_documents"],
|
||||
"successRate": self._percent(success_runs, total_runs),
|
||||
"failureRate": self._percent(failed_runs, total_runs),
|
||||
}
|
||||
|
||||
def _daily_work(self, labels: list[str], runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = {
|
||||
label: {
|
||||
"date": label,
|
||||
"total": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"running": 0,
|
||||
"riskObservations": 0,
|
||||
"riskClues": 0,
|
||||
"profileSnapshots": 0,
|
||||
"knowledgeDocuments": 0,
|
||||
"businessOutputs": 0,
|
||||
}
|
||||
for label in labels
|
||||
}
|
||||
|
||||
for run in runs:
|
||||
label = self._date_label(run.started_at)
|
||||
if label not in rows:
|
||||
continue
|
||||
row = rows[label]
|
||||
metrics = self._extract_run_metrics(run)
|
||||
row["total"] += 1
|
||||
if self._is_success(run.status):
|
||||
row["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
row["failed"] += 1
|
||||
elif self._is_running(run.status):
|
||||
row["running"] += 1
|
||||
row["riskObservations"] += metrics["risk_observations"]
|
||||
row["riskClues"] += metrics["risk_clues"]
|
||||
row["profileSnapshots"] += metrics["profile_snapshots"]
|
||||
row["knowledgeDocuments"] += metrics["knowledge_documents"]
|
||||
row["businessOutputs"] += (
|
||||
metrics["risk_observations"]
|
||||
+ metrics["risk_clues"]
|
||||
+ metrics["profile_snapshots"]
|
||||
+ metrics["knowledge_documents"]
|
||||
)
|
||||
|
||||
return [rows[label] for label in labels]
|
||||
|
||||
def _task_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, dict[str, Any]] = {}
|
||||
for run in runs:
|
||||
task_type = self._resolve_task_type(run)
|
||||
spec = self._task_spec(task_type)
|
||||
bucket = buckets.setdefault(
|
||||
task_type or "unknown",
|
||||
{
|
||||
"taskType": task_type or "unknown",
|
||||
"name": spec["label"],
|
||||
"category": spec["category"],
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"value": 0,
|
||||
"color": spec["color"],
|
||||
},
|
||||
)
|
||||
bucket["count"] += 1
|
||||
bucket["value"] += 1
|
||||
if self._is_success(run.status):
|
||||
bucket["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
bucket["failed"] += 1
|
||||
|
||||
return sorted(buckets.values(), key=lambda item: (-item["count"], item["name"]))[:8]
|
||||
|
||||
def _category_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = {
|
||||
category: {
|
||||
"name": category,
|
||||
"value": 0,
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"color": spec["color"],
|
||||
"description": spec["description"],
|
||||
}
|
||||
for category, spec in CATEGORY_SPECS.items()
|
||||
}
|
||||
for run in runs:
|
||||
category = self._task_spec(self._resolve_task_type(run))["category"]
|
||||
row = rows.setdefault(
|
||||
category,
|
||||
{
|
||||
"name": category,
|
||||
"value": 0,
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"color": "var(--theme-primary)",
|
||||
"description": "其他数字员工工作",
|
||||
},
|
||||
)
|
||||
row["value"] += 1
|
||||
row["count"] += 1
|
||||
if self._is_success(run.status):
|
||||
row["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
row["failed"] += 1
|
||||
return list(rows.values())
|
||||
|
||||
def _recent_runs(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for run in sorted(runs, key=lambda item: item.started_at, reverse=True)[:8]:
|
||||
task_type = self._resolve_task_type(run)
|
||||
spec = self._task_spec(task_type)
|
||||
rows.append(
|
||||
{
|
||||
"runId": run.run_id,
|
||||
"taskType": task_type or "unknown",
|
||||
"taskLabel": spec["label"],
|
||||
"category": spec["category"],
|
||||
"status": run.status,
|
||||
"statusLabel": self._status_label(run.status),
|
||||
"statusTone": self._status_tone(run.status),
|
||||
"source": run.source,
|
||||
"sourceLabel": self._source_label(run.source),
|
||||
"startedAt": self._iso(run.started_at),
|
||||
"finishedAt": self._iso(run.finished_at),
|
||||
"durationMs": self._duration_ms(run),
|
||||
"summary": self._summary_text(run),
|
||||
"metrics": self._extract_run_metrics(run),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def _sum_metrics(self, runs: list[AgentRun]) -> dict[str, int]:
|
||||
totals = self._empty_metrics()
|
||||
for run in runs:
|
||||
metrics = self._extract_run_metrics(run)
|
||||
for key in totals:
|
||||
totals[key] += int(metrics.get(key) or 0)
|
||||
return totals
|
||||
|
||||
def _extract_run_metrics(self, run: AgentRun) -> dict[str, int]:
|
||||
summary = self._extract_run_summary(run)
|
||||
route_json = run.route_json or {}
|
||||
metrics = self._empty_metrics()
|
||||
metrics["risk_observations"] = self._first_int(
|
||||
summary,
|
||||
("risk_observation_count", "risk_observations", "created_observation_count"),
|
||||
)
|
||||
metrics["risk_clues"] = self._first_int(
|
||||
summary,
|
||||
("risk_clue_count", "risk_clues", "created_clue_count"),
|
||||
)
|
||||
metrics["profile_snapshots"] = self._first_int(
|
||||
summary,
|
||||
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
|
||||
)
|
||||
metrics["knowledge_documents"] = max(
|
||||
self._first_int(
|
||||
summary,
|
||||
("knowledge_document_count", "document_count", "processed_document_count"),
|
||||
),
|
||||
self._list_length(summary, ("document_ids", "requested_document_ids")),
|
||||
self._list_length(route_json, ("document_ids", "requested_document_ids")),
|
||||
)
|
||||
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
|
||||
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
|
||||
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
|
||||
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
|
||||
return metrics
|
||||
|
||||
@staticmethod
|
||||
def _empty_metrics() -> dict[str, int]:
|
||||
return {
|
||||
"risk_observations": 0,
|
||||
"risk_clues": 0,
|
||||
"profile_snapshots": 0,
|
||||
"knowledge_documents": 0,
|
||||
"scanned_claims": 0,
|
||||
"target_employees": 0,
|
||||
"rule_hits": 0,
|
||||
"facts": 0,
|
||||
}
|
||||
|
||||
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
|
||||
task_type = self._resolve_task_type(run)
|
||||
matched_tool = self._matched_tool_call(run, task_type)
|
||||
if matched_tool is None:
|
||||
return run.route_json or {}
|
||||
response = matched_tool.response_json or {}
|
||||
if isinstance(response, dict) and isinstance(response.get("summary"), dict):
|
||||
return response["summary"]
|
||||
return response if isinstance(response, dict) else {}
|
||||
|
||||
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
|
||||
digital_tools = [
|
||||
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
|
||||
]
|
||||
for tool in run.tool_calls:
|
||||
candidates = [
|
||||
(tool.request_json or {}).get("task_type"),
|
||||
(tool.request_json or {}).get("job_type"),
|
||||
(tool.response_json or {}).get("report_type"),
|
||||
(tool.response_json or {}).get("task_type"),
|
||||
(tool.response_json or {}).get("job_type"),
|
||||
self._task_type_from_tool_name(tool.tool_name),
|
||||
]
|
||||
if task_type and task_type in {self._normalize_task_type(item) for item in candidates}:
|
||||
return tool
|
||||
if digital_tools:
|
||||
return digital_tools[0]
|
||||
return run.tool_calls[0] if run.tool_calls else None
|
||||
|
||||
def _is_digital_employee_run(self, run: AgentRun) -> bool:
|
||||
task_type = self._resolve_task_type(run)
|
||||
if task_type in TASK_SPECS:
|
||||
return True
|
||||
if run.agent == AgentName.HERMES.value:
|
||||
return True
|
||||
if run.source == AgentRunSource.SCHEDULE.value and task_type:
|
||||
return True
|
||||
route_json = run.route_json or {}
|
||||
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
|
||||
return True
|
||||
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
|
||||
|
||||
def _resolve_task_type(self, run: AgentRun) -> str:
|
||||
route_json = run.route_json or {}
|
||||
route_candidates = [
|
||||
route_json.get("job_type"),
|
||||
route_json.get("task_type"),
|
||||
route_json.get("report_type"),
|
||||
route_json.get("task_code"),
|
||||
route_json.get("code"),
|
||||
]
|
||||
for candidate in route_candidates:
|
||||
normalized = self._normalize_task_type(candidate)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
for tool in run.tool_calls:
|
||||
for candidate in (
|
||||
(tool.request_json or {}).get("task_type"),
|
||||
(tool.request_json or {}).get("job_type"),
|
||||
(tool.response_json or {}).get("report_type"),
|
||||
(tool.response_json or {}).get("task_type"),
|
||||
(tool.response_json or {}).get("job_type"),
|
||||
self._task_type_from_tool_name(tool.tool_name),
|
||||
):
|
||||
normalized = self._normalize_task_type(candidate)
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_task_type(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = TASK_CODE_TO_TYPE.get(text, text)
|
||||
if text.startswith("task.hermes."):
|
||||
text = text.removeprefix("task.hermes.")
|
||||
text = text.replace("-", "_").replace(".", "_")
|
||||
if text == "risk_rule_discovery":
|
||||
return "risk_clue_collect"
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _task_type_from_tool_name(value: str | None) -> str:
|
||||
name = str(value or "")
|
||||
if "financial_risk_graph" in name:
|
||||
return "global_risk_scan"
|
||||
if "employee_behavior_profile" in name:
|
||||
return "employee_behavior_profile_scan"
|
||||
if "finance_policy_knowledge" in name:
|
||||
return "finance_policy_knowledge_organize"
|
||||
if "risk_clue" in name:
|
||||
return "risk_clue_collect"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _task_spec(task_type: str) -> dict[str, str]:
|
||||
return TASK_SPECS.get(
|
||||
task_type,
|
||||
{
|
||||
"label": "数字员工工作",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
)
|
||||
|
||||
def _summary_text(self, run: AgentRun) -> str:
|
||||
text = str(run.result_summary or "").strip()
|
||||
if text:
|
||||
return text
|
||||
summary = self._extract_run_summary(run)
|
||||
for key in ("message", "summary", "result_summary"):
|
||||
value = str(summary.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
if run.error_message:
|
||||
return str(run.error_message)
|
||||
return "暂无摘要。"
|
||||
|
||||
@staticmethod
|
||||
def _first_int(payload: Any, keys: tuple[str, ...]) -> int:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
return int(value)
|
||||
for value in payload.values():
|
||||
found = DigitalEmployeeDashboardService._first_int(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for value in payload:
|
||||
found = DigitalEmployeeDashboardService._first_int(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _list_length(payload: Any, keys: tuple[str, ...]) -> int:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
for value in payload.values():
|
||||
found = DigitalEmployeeDashboardService._list_length(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for value in payload:
|
||||
found = DigitalEmployeeDashboardService._list_length(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _percent(value: int | float, total: int | float) -> float:
|
||||
if not total:
|
||||
return 0.0
|
||||
return round((float(value) / float(total)) * 100, 1)
|
||||
|
||||
@staticmethod
|
||||
def _duration_ms(run: AgentRun) -> int:
|
||||
if not run.finished_at:
|
||||
return 0
|
||||
try:
|
||||
finished_at = DigitalEmployeeDashboardService._as_utc(run.finished_at)
|
||||
started_at = DigitalEmployeeDashboardService._as_utc(run.started_at)
|
||||
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||
except TypeError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _date_labels(start_date: date, days: int) -> list[str]:
|
||||
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
|
||||
|
||||
@staticmethod
|
||||
def _date_label(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return DigitalEmployeeDashboardService._as_utc(value).strftime("%m-%d")
|
||||
|
||||
@staticmethod
|
||||
def _iso(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return DigitalEmployeeDashboardService._as_utc(value).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
@staticmethod
|
||||
def _is_success(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in SUCCESS_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _is_failed(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in FAILED_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _is_running(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in RUNNING_STATUSES
|
||||
|
||||
def _status_label(self, status: str | None) -> str:
|
||||
if self._is_success(status):
|
||||
return "成功"
|
||||
if self._is_failed(status):
|
||||
return "失败"
|
||||
if self._is_running(status):
|
||||
return "运行中"
|
||||
return str(status or "其他")
|
||||
|
||||
def _status_tone(self, status: str | None) -> str:
|
||||
if self._is_success(status):
|
||||
return "success"
|
||||
if self._is_failed(status):
|
||||
return "danger"
|
||||
if self._is_running(status):
|
||||
return "warning"
|
||||
return "neutral"
|
||||
|
||||
@staticmethod
|
||||
def _source_label(source: str | None) -> str:
|
||||
labels = {
|
||||
"schedule": "定时任务",
|
||||
"system_event": "系统事件",
|
||||
"user_message": "用户触发",
|
||||
}
|
||||
text = str(source or "").strip()
|
||||
return labels.get(text, text or "未标记")
|
||||
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.algorithem.employee_behavior_profile import (
|
||||
@@ -102,7 +102,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
commit: bool = True,
|
||||
) -> list[EmployeeBehaviorProfileSnapshot]:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
requested_employee_id = str(employee_id or "").strip()
|
||||
employee = self._resolve_employee_by_identifier(requested_employee_id)
|
||||
if employee is None:
|
||||
return []
|
||||
|
||||
@@ -161,10 +162,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
expense_type_scope: str = "overall",
|
||||
) -> EmployeeProfileLatestRead:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
requested_employee_id = str(employee_id or "").strip()
|
||||
employee = self._resolve_employee_by_identifier(requested_employee_id)
|
||||
if employee is None:
|
||||
return EmployeeProfileLatestRead(
|
||||
employee_id=employee_id,
|
||||
employee_id=requested_employee_id,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
@@ -172,22 +174,23 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
)
|
||||
|
||||
resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope)
|
||||
resolved_employee_id = employee.id
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
)
|
||||
if not rows and claim_id:
|
||||
self.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=(window_days,),
|
||||
expense_type_scope=resolved_scope,
|
||||
source_task_type="api_on_demand",
|
||||
claim_id=claim_id,
|
||||
)
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
@@ -201,6 +204,31 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
expense_type_scope=resolved_scope,
|
||||
)
|
||||
|
||||
def _resolve_employee_by_identifier(self, identifier: str) -> Employee | None:
|
||||
normalized = str(identifier or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
employee = self.db.get(Employee, normalized)
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
normalized_email = normalized.lower()
|
||||
conditions = [
|
||||
Employee.name == normalized,
|
||||
Employee.employee_no == normalized,
|
||||
]
|
||||
if "@" in normalized_email:
|
||||
conditions.append(func.lower(Employee.email) == normalized_email)
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.where(or_(*conditions))
|
||||
.order_by(Employee.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def _build_window_context(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
return self.is_department_p8_budget_monitor(current_user, claim)
|
||||
return self.is_department_budget_approver(current_user, claim)
|
||||
|
||||
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
@@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy:
|
||||
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
|
||||
|
||||
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return False
|
||||
return self.is_department_budget_approver(current_user, claim)
|
||||
|
||||
def is_department_budget_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is None:
|
||||
return False
|
||||
role_codes |= self._collect_employee_role_codes(current_employee)
|
||||
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
|
||||
return False
|
||||
if not self._employee_has_budget_approval_grade(current_employee):
|
||||
return False
|
||||
|
||||
@@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy:
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||
.where(
|
||||
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
|
||||
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
|
||||
Employee.roles.any(Role.role_code.in_(BUDGET_APPROVAL_ROLE_CODES)),
|
||||
or_(*department_conditions),
|
||||
)
|
||||
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||
@@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy:
|
||||
stmt = stmt.where(Employee.id != claim_employee_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def resolve_budget_approval_role_code(self, employee: Employee | None) -> str:
|
||||
role_codes = self._collect_employee_role_codes(employee)
|
||||
for role_code in ("budget_monitor", "executive"):
|
||||
if role_code in role_codes:
|
||||
return role_code
|
||||
return BUDGET_MONITOR_ROLE_CODE
|
||||
|
||||
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||
if claim is None:
|
||||
return None
|
||||
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
return claim
|
||||
|
||||
budget_manager = self.resolve_department_budget_manager(claim)
|
||||
if budget_manager is None:
|
||||
return claim
|
||||
|
||||
setattr(claim, "budget_approver_name", str(budget_manager.name or "").strip())
|
||||
setattr(claim, "budget_approver_grade", str(budget_manager.grade or "").strip())
|
||||
setattr(
|
||||
claim,
|
||||
"budget_approver_role_code",
|
||||
self.resolve_budget_approval_role_code(budget_manager),
|
||||
)
|
||||
return claim
|
||||
|
||||
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||
for claim in claims:
|
||||
self.attach_budget_approval_snapshot(claim)
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
return {
|
||||
@@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy:
|
||||
if str(item).strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _collect_employee_role_codes(employee: Employee | None) -> set[str]:
|
||||
if employee is None:
|
||||
return set()
|
||||
return {
|
||||
str(role.role_code or "").strip().lower()
|
||||
for role in list(employee.roles or [])
|
||||
if str(role.role_code or "").strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
|
||||
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
|
||||
@@ -293,6 +334,7 @@ class ExpenseClaimAccessPolicy:
|
||||
[
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
str(current_user.employee_no or "").strip(),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -309,9 +351,10 @@ class ExpenseClaimAccessPolicy:
|
||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||
|
||||
def is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
|
||||
claim_employee_id = str(claim.employee_id or "").strip()
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is not None:
|
||||
if str(claim.employee_id or "").strip() == current_employee.id:
|
||||
if claim_employee_id == current_employee.id:
|
||||
return True
|
||||
identity_values = {
|
||||
str(current_employee.name or "").strip(),
|
||||
@@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy:
|
||||
{
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
str(current_user.employee_no or "").strip(),
|
||||
}
|
||||
)
|
||||
identity_values.discard("")
|
||||
if claim_employee_id and claim_employee_id in identity_values:
|
||||
return True
|
||||
return str(claim.employee_name or "").strip() in identity_values
|
||||
|
||||
@staticmethod
|
||||
@@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy:
|
||||
add_condition("employee_name", employee.name)
|
||||
else:
|
||||
add_condition("employee_id", username)
|
||||
add_condition("employee_id", str(current_user.employee_no or "").strip())
|
||||
add_condition("employee_name", username)
|
||||
add_condition("employee_name", str(current_user.name or "").strip())
|
||||
add_condition("employee_name", str(current_user.employee_no or "").strip())
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy:
|
||||
return conditions
|
||||
|
||||
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return []
|
||||
employee = self.resolve_current_employee(current_user)
|
||||
role_codes = self.normalize_role_codes(current_user) | self._collect_employee_role_codes(employee)
|
||||
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
|
||||
return []
|
||||
if employee is None or not self._employee_has_budget_approval_grade(employee):
|
||||
return []
|
||||
|
||||
@@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
if current_user.is_admin:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
conditions = []
|
||||
if "finance" in role_codes:
|
||||
|
||||
@@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import (
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalFlowMixin:
|
||||
@@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
||||
next_budget_manager = None
|
||||
merged_budget_approval = False
|
||||
route_decision_flag: dict[str, Any] | None = None
|
||||
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
route_decision_flag = self._build_approval_route_decision(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
)
|
||||
requires_budget_review = bool(route_decision_flag.get("requires_budget_review"))
|
||||
if is_application_claim:
|
||||
merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
merged_budget_approval = (
|
||||
requires_budget_review
|
||||
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
)
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
if requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该单据。")
|
||||
approval_source = "budget_approval"
|
||||
event_type = (
|
||||
"expense_application_budget_approval"
|
||||
if is_application_claim
|
||||
else "expense_claim_budget_approval"
|
||||
)
|
||||
label = "预算管理者审核通过"
|
||||
if is_application_claim:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not is_application_claim:
|
||||
raise ValueError("只有费用申请需要预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
|
||||
approval_source = "budget_approval"
|
||||
event_type = "expense_application_budget_approval"
|
||||
label = "预算管理者审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
default_message = "{operator} 已完成预算审核,流转至{next_stage}。"
|
||||
elif previous_stage == FINANCE_APPROVAL_STAGE:
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请需先完成预算管理者审批。")
|
||||
@@ -95,32 +130,35 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
|
||||
if consumed_budget_flag is not None:
|
||||
budget_flags.append(consumed_budget_flag)
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
approval_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
if merged_budget_approval:
|
||||
approval_flag.update(
|
||||
{
|
||||
"budget_approval_merged": True,
|
||||
"budget_approval_merged_reason": "direct_manager_is_department_budget_monitor",
|
||||
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
|
||||
}
|
||||
)
|
||||
if next_budget_manager is not None:
|
||||
@@ -129,9 +167,20 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"next_approver_name": str(next_budget_manager.name or "").strip(),
|
||||
"next_approver_employee_id": next_budget_manager.id,
|
||||
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
|
||||
"next_approver_role_code": "budget_monitor",
|
||||
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
),
|
||||
}
|
||||
)
|
||||
if route_decision_flag is not None:
|
||||
approval_flag["route_decision"] = {
|
||||
"requires_budget_review": route_decision_flag.get("requires_budget_review"),
|
||||
"route": route_decision_flag.get("route"),
|
||||
"reasons": route_decision_flag.get("reasons", []),
|
||||
"budget_result": route_decision_flag.get("budget_result", {}),
|
||||
"current_risk_count": route_decision_flag.get("current_risk_count", 0),
|
||||
"historical_risk_count": route_decision_flag.get("historical_risk_count", 0),
|
||||
}
|
||||
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
@@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
elif merged_budget_approval:
|
||||
approval_flag["leader_opinion"] = approval_opinion
|
||||
approval_flag["budget_opinion"] = approval_opinion
|
||||
elif (
|
||||
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and route_decision_flag is not None
|
||||
and not route_decision_flag.get("requires_budget_review")
|
||||
):
|
||||
approval_flag["leader_opinion"] = approval_opinion
|
||||
approval_flag["budget_opinion"] = "系统动态路由跳过预算复核"
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
@@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
generated_draft.risk_flags_json = self._append_budget_flags(
|
||||
generated_draft.risk_flags_json,
|
||||
transferred_budget_flag,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
approval_flags: list[Any] = list(claim.risk_flags_json or [])
|
||||
if route_decision_flag is not None:
|
||||
approval_flags.append(route_decision_flag)
|
||||
approval_flags.append(approval_flag)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), approval_flag],
|
||||
approval_flags,
|
||||
budget_flags,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._access_policy.attach_budget_approval_snapshot(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
@@ -202,26 +265,29 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
payment_flag = {
|
||||
"source": "payment",
|
||||
"event_type": "expense_claim_payment_completed",
|
||||
"payment_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "付款完成",
|
||||
"message": f"{operator} 已确认付款,报销单进入已付款。",
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": PAYMENT_PAID_STATUS,
|
||||
"next_approval_stage": PAYMENT_PAID_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
payment_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "payment",
|
||||
"event_type": "expense_claim_payment_completed",
|
||||
"payment_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "付款完成",
|
||||
"message": f"{operator} 已确认付款,报销单进入已付款。",
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": PAYMENT_PAID_STATUS,
|
||||
"next_approval_stage": PAYMENT_PAID_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
|
||||
227
server/src/app/services/expense_claim_approval_routing.py
Normal file
227
server/src/app/services/expense_claim_approval_routing.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_constants import AI_REVIEW_LOOKBACK_DAYS
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
risk_flag_business_stage,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalRoutingMixin:
|
||||
_BUDGET_REVIEW_RATINGS = {"block"}
|
||||
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
|
||||
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
|
||||
_ROUTE_RISK_SOURCES = {
|
||||
"attachment_analysis",
|
||||
"budget",
|
||||
"budget_control",
|
||||
"manual_return",
|
||||
"platform_risk",
|
||||
"platform_risk_rule",
|
||||
"policy_review",
|
||||
"risk_rule",
|
||||
"scene_policy",
|
||||
"submission_review",
|
||||
"travel_policy",
|
||||
}
|
||||
_ROUTE_IGNORED_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"payment",
|
||||
}
|
||||
_ROUTE_RISK_EVENT_TYPES = {
|
||||
"budget_frozen",
|
||||
"budget_insufficient",
|
||||
"budget_missing",
|
||||
"platform_risk_rule_hit",
|
||||
"risk_rule_hit",
|
||||
}
|
||||
_BUDGET_ROUTE_EVENT_TYPES = {
|
||||
"budget_frozen",
|
||||
"budget_insufficient",
|
||||
"budget_missing",
|
||||
}
|
||||
|
||||
def _build_approval_route_decision(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
is_application_claim: bool,
|
||||
) -> dict[str, Any]:
|
||||
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||
budget_reasons = self._collect_budget_route_reasons(budget_result)
|
||||
current_risk_reasons = self._collect_current_route_risk_reasons(
|
||||
claim.risk_flags_json,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
|
||||
historical_risk_reasons = (
|
||||
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
|
||||
if historical_risk_count > 0
|
||||
else []
|
||||
)
|
||||
reasons = self._dedupe_reasons(
|
||||
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
||||
)
|
||||
requires_budget_review = bool(reasons)
|
||||
route = (
|
||||
"budget_manager"
|
||||
if requires_budget_review
|
||||
else "approval_done"
|
||||
if is_application_claim
|
||||
else "finance"
|
||||
)
|
||||
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
||||
message = (
|
||||
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
|
||||
if requires_budget_review
|
||||
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
|
||||
)
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "approval_routing",
|
||||
"event_type": (
|
||||
"expense_application_route_decision"
|
||||
if is_application_claim
|
||||
else "expense_claim_route_decision"
|
||||
),
|
||||
"severity": "medium" if requires_budget_review else "info",
|
||||
"label": label,
|
||||
"message": message,
|
||||
"requires_budget_review": requires_budget_review,
|
||||
"route": route,
|
||||
"reasons": reasons,
|
||||
"budget_result": self._compact_budget_result(budget_result),
|
||||
"current_risk_count": len(current_risk_reasons),
|
||||
"historical_risk_count": historical_risk_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
|
||||
def _collect_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
|
||||
rating = str(budget_result.get("rating") or "").strip().lower()
|
||||
risk_level = str(budget_result.get("risk_level") or "").strip().lower()
|
||||
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
context = (
|
||||
budget_result.get("budget_context")
|
||||
if isinstance(budget_result.get("budget_context"), dict)
|
||||
else {}
|
||||
)
|
||||
reasons: list[str] = []
|
||||
if context.get("budget_applicable") is True and context.get("matched") is False:
|
||||
reasons.append("未匹配到可用预算池")
|
||||
if rating in self._BUDGET_REVIEW_RATINGS:
|
||||
summary = str(budget_result.get("summary") or "").strip()
|
||||
reasons.append(summary or f"预算测算评级为 {rating}")
|
||||
if risk_level in self._BUDGET_REVIEW_RISK_LEVELS:
|
||||
reasons.append(f"预算风险等级为 {risk_level}")
|
||||
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
|
||||
if over_budget_amount > Decimal("0.00"):
|
||||
reasons.append(f"预计超预算 {over_budget_amount} 元")
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _collect_current_route_risk_reasons(
|
||||
self,
|
||||
risk_flags: list[Any] | None,
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
flag_stage = risk_flag_business_stage(flag)
|
||||
if flag_stage and flag_stage != business_stage:
|
||||
continue
|
||||
if not self._is_substantive_route_risk_flag(flag):
|
||||
continue
|
||||
label = str(flag.get("label") or flag.get("event_type") or "风险标记").strip()
|
||||
message = str(flag.get("message") or "").strip()
|
||||
reasons.append(f"{label}:{message}" if message else label)
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
|
||||
filters = []
|
||||
if claim.employee_id:
|
||||
filters.append(ExpenseClaim.employee_id == claim.employee_id)
|
||||
elif claim.employee_name:
|
||||
filters.append(ExpenseClaim.employee_name == claim.employee_name)
|
||||
if not filters:
|
||||
return 0
|
||||
|
||||
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(or_(*filters))
|
||||
.where(ExpenseClaim.id != claim.id)
|
||||
.where(ExpenseClaim.occurred_at >= since)
|
||||
)
|
||||
return sum(
|
||||
1
|
||||
for item in self.db.scalars(stmt).all()
|
||||
if any(
|
||||
self._is_substantive_route_risk_flag(flag)
|
||||
for flag in list(item.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
)
|
||||
)
|
||||
|
||||
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
if source in self._ROUTE_IGNORED_SOURCES:
|
||||
return False
|
||||
|
||||
event_type = str(flag.get("event_type") or "").strip().lower()
|
||||
severity = str(flag.get("severity") or "").strip().lower()
|
||||
if source in {"budget", "budget_control"}:
|
||||
return event_type in self._BUDGET_ROUTE_EVENT_TYPES or severity in {"high", "critical", "danger"}
|
||||
if event_type in self._ROUTE_RISK_EVENT_TYPES:
|
||||
return True
|
||||
if severity in self._ROUTE_RISK_SEVERITIES:
|
||||
return source in self._ROUTE_RISK_SOURCES or bool(source)
|
||||
return source in self._ROUTE_RISK_SOURCES and bool(flag.get("triggered"))
|
||||
|
||||
@staticmethod
|
||||
def _compact_budget_result(budget_result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"score": budget_result.get("score"),
|
||||
"rating": budget_result.get("rating"),
|
||||
"risk_level": budget_result.get("risk_level"),
|
||||
"summary": budget_result.get("summary"),
|
||||
"metrics": budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_reasons(reasons: list[str]) -> list[str]:
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for reason in reasons:
|
||||
text = str(reason or "").strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
deduped.append(text)
|
||||
return deduped
|
||||
|
||||
@staticmethod
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
@@ -277,6 +277,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"item_location": item.item_location,
|
||||
"item_amount": item.item_amount,
|
||||
"claim_amount": claim.amount,
|
||||
"claim_risk_flags": list(claim.risk_flags_json or []),
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
@@ -371,6 +372,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"claim_risk_flags": list(claim.risk_flags_json or []),
|
||||
"attachment": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
|
||||
|
||||
class ExpenseClaimBudgetFlowMixin:
|
||||
@@ -80,6 +81,8 @@ class ExpenseClaimBudgetFlowMixin:
|
||||
def _append_budget_flags(
|
||||
risk_flags: list[Any] | None,
|
||||
budget_flags: list[dict[str, Any]] | dict[str, Any] | None,
|
||||
*,
|
||||
business_stage: str | None = None,
|
||||
) -> list[Any]:
|
||||
if budget_flags is None:
|
||||
return list(risk_flags or [])
|
||||
@@ -89,7 +92,19 @@ class ExpenseClaimBudgetFlowMixin:
|
||||
next_flags = list(budget_flags or [])
|
||||
if not next_flags:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), *next_flags]
|
||||
enriched_flags = [
|
||||
enrich_risk_flag_semantics(
|
||||
flag,
|
||||
business_stage=business_stage,
|
||||
risk_domain="budget",
|
||||
visibility_scope="budget_manager",
|
||||
actionability="budget_governance",
|
||||
)
|
||||
if isinstance(flag, dict)
|
||||
else flag
|
||||
for flag in next_flags
|
||||
]
|
||||
return [*list(risk_flags or []), *enriched_flags]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:
|
||||
|
||||
@@ -224,6 +224,10 @@ class ExpenseClaimDraftFlowMixin:
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
@@ -347,6 +351,7 @@ class ExpenseClaimDraftFlowMixin:
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
@@ -374,6 +379,86 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _merge_application_link_flag(
|
||||
risk_flags: list[Any],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json)
|
||||
if link_flag is None:
|
||||
return list(risk_flags or [])
|
||||
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
existing_no = str(
|
||||
flag.get("application_claim_no")
|
||||
or flag.get("applicationClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
if existing_no and existing_no == application_claim_no:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), link_flag]
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
||||
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("expense_scene_selection")
|
||||
)
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in keys:
|
||||
value = str(source.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
||||
if not application_claim_no:
|
||||
return None
|
||||
|
||||
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
||||
application_amount = pick("application_amount", "applicationAmount")
|
||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
||||
application_location = pick("application_location", "applicationLocation", "location")
|
||||
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
|
||||
application_status = pick("application_status", "applicationStatus")
|
||||
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
||||
|
||||
return {
|
||||
"source": "application_link",
|
||||
"event_type": "expense_reimbursement_application_linked",
|
||||
"severity": "info",
|
||||
"label": "关联申请单",
|
||||
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
||||
"application_claim_id": application_claim_id,
|
||||
"application_claim_no": application_claim_no,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_status": application_status,
|
||||
"application_status_label": application_status_label,
|
||||
"application_detail": {
|
||||
"application_reason": application_reason,
|
||||
"application_location": application_location,
|
||||
"application_amount": application_amount,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_time": application_date,
|
||||
},
|
||||
"review_form_values": review_values,
|
||||
"expense_scene_selection": scene_selection,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_rule_runtime import (
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
@@ -215,6 +216,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
return
|
||||
|
||||
ordered_items = sorted(
|
||||
@@ -253,6 +255,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
claim,
|
||||
self._build_claim_attachment_risk_flags(ordered_items),
|
||||
)
|
||||
self._refresh_claim_platform_risk_preview_flags(claim)
|
||||
if str(claim.status or "").strip().lower() == "draft":
|
||||
claim.approval_stage = "待提交"
|
||||
|
||||
@@ -359,15 +362,18 @@ class ExpenseClaimItemSyncMixin:
|
||||
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
|
||||
).strip()
|
||||
derived_flags.append(
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"item_id": item.id,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": f"费用明细第 {index} 条:{message_detail}",
|
||||
"summary": summary,
|
||||
"points": points,
|
||||
}
|
||||
with_risk_business_stage(
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"item_id": item.id,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": f"费用明细第 {index} 条:{message_detail}",
|
||||
"summary": summary,
|
||||
"points": points,
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
)
|
||||
return derived_flags
|
||||
|
||||
@@ -412,6 +418,38 @@ class ExpenseClaimItemSyncMixin:
|
||||
]
|
||||
return preserved_flags + attachment_risk_flags
|
||||
|
||||
def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None:
|
||||
if str(claim.expense_type or "").strip().lower().endswith("_application"):
|
||||
return
|
||||
evaluator = getattr(self, "evaluate_platform_risk_rules", None)
|
||||
if not callable(evaluator):
|
||||
return
|
||||
try:
|
||||
review = evaluator(claim, business_stage="reimbursement")
|
||||
except Exception:
|
||||
return
|
||||
platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else []
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(
|
||||
claim,
|
||||
platform_flags,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_claim_platform_risk_preview_flags(
|
||||
claim: ExpenseClaim,
|
||||
platform_flags: list[dict[str, Any]],
|
||||
) -> list[Any]:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||||
)
|
||||
]
|
||||
return preserved_flags + platform_flags
|
||||
|
||||
@staticmethod
|
||||
def _format_submission_blocked_message(issues: list[str]) -> str:
|
||||
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||||
|
||||
@@ -29,7 +29,9 @@ class ExpenseClaimPaginationMixin:
|
||||
ExpenseClaim.occurred_at.desc(),
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
def list_approval_claims_page(
|
||||
self,
|
||||
@@ -43,7 +45,9 @@ class ExpenseClaimPaginationMixin:
|
||||
ExpenseClaim.created_at.desc(),
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
def list_archived_claims_page(
|
||||
self,
|
||||
|
||||
@@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||
|
||||
def evaluate_platform_risk_rules(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
rule_codes: list[str] | None = None,
|
||||
business_stage: str | None = None,
|
||||
) -> dict[str, list[Any]]:
|
||||
manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes)
|
||||
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
||||
manifests = self._load_platform_risk_rule_manifests(
|
||||
rule_codes=rule_codes,
|
||||
business_stage=normalized_stage,
|
||||
)
|
||||
if not manifests:
|
||||
return {"flags": [], "blocking_reasons": []}
|
||||
|
||||
@@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
self,
|
||||
*,
|
||||
rule_codes: list[str] | None,
|
||||
business_stage: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
code_filter = {
|
||||
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
|
||||
@@ -117,7 +127,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
manifest_code = str(payload.get("rule_code") or rule_code).strip()
|
||||
if not manifest_code or (code_filter and manifest_code not in code_filter):
|
||||
continue
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
|
||||
payload = dict(payload)
|
||||
@@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
continue
|
||||
if code_filter and rule_code not in missing_codes:
|
||||
continue
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
payload = dict(payload)
|
||||
payload["_rule_version"] = "v1.0.0"
|
||||
@@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
|
||||
return list(manifests_by_code.values())
|
||||
|
||||
@classmethod
|
||||
def _normalize_platform_risk_business_stage(cls, value: str | None) -> str:
|
||||
normalized = str(value or cls._DEFAULT_RISK_BUSINESS_STAGE).strip().lower()
|
||||
if not normalized or normalized not in cls._SUPPORTED_RISK_BUSINESS_STAGES:
|
||||
return cls._DEFAULT_RISK_BUSINESS_STAGE
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _risk_manifest_matches_business_stage(
|
||||
cls,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
business_stage: str | None,
|
||||
) -> bool:
|
||||
if not business_stage:
|
||||
return True
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
raw_stages = applies_to.get("business_stages")
|
||||
if not isinstance(raw_stages, list):
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
raw_stages = [manifest.get("business_stage") or metadata.get("business_stage") or cls._DEFAULT_RISK_BUSINESS_STAGE]
|
||||
stages = {
|
||||
cls._normalize_platform_risk_business_stage(str(item))
|
||||
for item in raw_stages
|
||||
if str(item or "").strip()
|
||||
}
|
||||
return business_stage in (stages or {cls._DEFAULT_RISK_BUSINESS_STAGE})
|
||||
|
||||
def _risk_manifest_applies_to_claim(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
@@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
configured_expense_types = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
|
||||
)
|
||||
configured_expense_categories = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_categories") or [])]
|
||||
)
|
||||
|
||||
if self._is_all_expense_scope(configured_expense_types):
|
||||
configured_expense_types = set()
|
||||
if self._is_all_expense_scope(configured_expense_categories):
|
||||
configured_expense_categories = set()
|
||||
|
||||
if configured_expense_types and not (expense_types & configured_expense_types):
|
||||
return False
|
||||
if configured_expense_categories and not (expense_types & configured_expense_categories):
|
||||
return False
|
||||
if domains and not self._risk_domains_match_claim(
|
||||
domains,
|
||||
expense_types=expense_types,
|
||||
@@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
if not raw:
|
||||
continue
|
||||
normalized.add(raw.lower())
|
||||
if raw in {"全部", "通用"}:
|
||||
normalized.add("all")
|
||||
if raw.lower().endswith("_application"):
|
||||
normalized.add(raw.lower().removesuffix("_application"))
|
||||
resolved = resolve_expense_type_code_from_text(raw)
|
||||
if resolved:
|
||||
normalized.add(resolved)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _is_all_expense_scope(values: set[str]) -> bool:
|
||||
return bool(values & {"all", "*", "overall", "general", "全部", "通用"})
|
||||
|
||||
def _risk_domains_match_claim(
|
||||
self,
|
||||
domains: set[str],
|
||||
@@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
|
||||
default_action = "block" if severity in {"high", "critical"} else "manual_review"
|
||||
action = str(fail_outcome.get("action") or default_action).strip()
|
||||
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
|
||||
|
||||
return {
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"evidence": evidence,
|
||||
}
|
||||
return build_platform_risk_flag(
|
||||
manifest,
|
||||
message=message,
|
||||
evidence=evidence,
|
||||
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _count_values(values: list[str]) -> dict[str, int]:
|
||||
|
||||
76
server/src/app/services/expense_claim_platform_risk_flag.py
Normal file
76
server/src/app/services/expense_claim_platform_risk_flag.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
infer_risk_domain,
|
||||
normalize_risk_business_stage,
|
||||
normalize_risk_actionability,
|
||||
normalize_risk_visibility_scope,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
def build_platform_risk_flag(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
default_business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
|
||||
default_action = "block" if severity in {"high", "critical"} else "manual_review"
|
||||
action = str(fail_outcome.get("action") or default_action).strip()
|
||||
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
raw_stages = applies_to.get("business_stages")
|
||||
applies_to_stage = raw_stages[0] if isinstance(raw_stages, list) and raw_stages else None
|
||||
business_stage = normalize_risk_business_stage(
|
||||
manifest.get("business_stage") or metadata.get("business_stage") or applies_to_stage,
|
||||
default=default_business_stage,
|
||||
)
|
||||
risk_domain = infer_risk_domain(manifest)
|
||||
default_visibility_scope = (
|
||||
"budget_manager"
|
||||
if risk_domain == "budget"
|
||||
else "leader"
|
||||
if business_stage == "expense_application"
|
||||
else "submitter"
|
||||
)
|
||||
default_actionability = (
|
||||
"budget_governance"
|
||||
if risk_domain == "budget"
|
||||
else "review_decision"
|
||||
if business_stage == "expense_application"
|
||||
else "fixable_by_submitter"
|
||||
)
|
||||
visibility_scope = normalize_risk_visibility_scope(
|
||||
metadata.get("visibility_scope") or manifest.get("visibility_scope"),
|
||||
default_visibility_scope,
|
||||
)
|
||||
actionability = normalize_risk_actionability(
|
||||
metadata.get("actionability") or manifest.get("actionability"),
|
||||
default_actionability,
|
||||
)
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"evidence": evidence,
|
||||
"risk_domain": risk_domain,
|
||||
"visibility_scope": visibility_scope,
|
||||
"actionability": actionability,
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_rule_runtime import (
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
@@ -135,7 +136,7 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
)
|
||||
|
||||
return {
|
||||
"flags": flags,
|
||||
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
|
||||
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
|
||||
}
|
||||
|
||||
@@ -393,7 +394,7 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
|
||||
|
||||
return {
|
||||
"flags": flags,
|
||||
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
|
||||
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
|
||||
}
|
||||
|
||||
|
||||
141
server/src/app/services/expense_claim_pre_review.py
Normal file
141
server/src/app/services/expense_claim_pre_review.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewMixin:
|
||||
def pre_review_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if not is_application_claim:
|
||||
self._sync_claim_from_items(claim)
|
||||
missing_fields = (
|
||||
self._validate_application_claim_for_submission(claim)
|
||||
if is_application_claim
|
||||
else self._validate_claim_for_submission(claim)
|
||||
)
|
||||
if missing_fields:
|
||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
reviewed_at = datetime.now(UTC)
|
||||
if is_application_claim:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||||
)
|
||||
]
|
||||
application_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])]
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
else:
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
review_flags = list(review_result.get("risk_flags") or [])
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
|
||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||
review_flags,
|
||||
self._build_ai_pre_review_flag(
|
||||
passed=passed,
|
||||
blocking_count=blocking_count,
|
||||
reviewed_at=reviewed_at,
|
||||
business_stage=risk_business_stage_for_claim(
|
||||
is_application_claim=is_application_claim,
|
||||
),
|
||||
),
|
||||
)
|
||||
claim.approval_stage = "AI预审" if not is_application_claim else claim.approval_stage
|
||||
claim.submitted_at = None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.pre_review",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _count_ai_pre_review_blocking_risks(risk_flags: list[Any]) -> int:
|
||||
return sum(
|
||||
1
|
||||
for flag in risk_flags
|
||||
if (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() != "ai_pre_review"
|
||||
and str(flag.get("severity") or "").strip().lower() == "high"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_ai_pre_review_flag(
|
||||
*,
|
||||
passed: bool,
|
||||
blocking_count: int,
|
||||
reviewed_at: datetime,
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
if passed:
|
||||
message = "AI预审通过,费用明细和附件可进入下一步提交审批。"
|
||||
else:
|
||||
message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步。"
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"event_type": "expense_claim_ai_pre_review",
|
||||
"severity": "info" if passed else "high",
|
||||
"label": "AI预审通过" if passed else "AI预审未通过",
|
||||
"message": message,
|
||||
"status": "passed" if passed else "failed",
|
||||
"passed": passed,
|
||||
"blocking_risk_count": blocking_count,
|
||||
"next_action": "next_step" if passed else "risk_explanation_required",
|
||||
"created_at": reviewed_at.isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _replace_ai_pre_review_flag(
|
||||
risk_flags: list[Any],
|
||||
next_flag: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(risk_flags or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "ai_pre_review"
|
||||
)
|
||||
]
|
||||
return [*preserved_flags, next_flag]
|
||||
@@ -15,6 +15,7 @@ from app.services.expense_claim_constants import (
|
||||
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
|
||||
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
|
||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.expense_claim_risk_review")
|
||||
@@ -159,6 +160,8 @@ class ExpenseClaimRiskReviewMixin(
|
||||
},
|
||||
)
|
||||
|
||||
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
|
||||
228
server/src/app/services/expense_claim_risk_stage.py
Normal file
228
server/src/app/services/expense_claim_risk_stage.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXPENSE_APPLICATION_BUSINESS_STAGE = "expense_application"
|
||||
REIMBURSEMENT_BUSINESS_STAGE = "reimbursement"
|
||||
SUPPORTED_RISK_BUSINESS_STAGES = {
|
||||
EXPENSE_APPLICATION_BUSINESS_STAGE,
|
||||
REIMBURSEMENT_BUSINESS_STAGE,
|
||||
}
|
||||
SUPPORTED_RISK_DOMAINS = {
|
||||
"budget",
|
||||
"policy",
|
||||
"invoice",
|
||||
"trip",
|
||||
"amount",
|
||||
"workflow",
|
||||
"profile",
|
||||
}
|
||||
SUPPORTED_RISK_VISIBILITY_SCOPES = {
|
||||
"submitter",
|
||||
"leader",
|
||||
"budget_manager",
|
||||
"finance",
|
||||
"admin",
|
||||
}
|
||||
SUPPORTED_RISK_ACTIONABILITIES = {
|
||||
"fixable_by_submitter",
|
||||
"review_decision",
|
||||
"budget_governance",
|
||||
"finance_check",
|
||||
"system_trace",
|
||||
}
|
||||
_NON_RISK_SYSTEM_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"application_submission",
|
||||
"approval",
|
||||
"approval_log",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"payment",
|
||||
"sla_reminder",
|
||||
"reminder",
|
||||
"urge",
|
||||
}
|
||||
|
||||
|
||||
def normalize_risk_business_stage(value: Any, default: str = REIMBURSEMENT_BUSINESS_STAGE) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_BUSINESS_STAGES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_domain(value: Any, default: str = "policy") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_DOMAINS:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_visibility_scope(value: Any, default: str = "leader") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_VISIBILITY_SCOPES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_actionability(value: Any, default: str = "review_decision") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_ACTIONABILITIES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def risk_business_stage_for_claim(*, is_application_claim: bool) -> str:
|
||||
return EXPENSE_APPLICATION_BUSINESS_STAGE if is_application_claim else REIMBURSEMENT_BUSINESS_STAGE
|
||||
|
||||
|
||||
def risk_flag_business_stage(flag: dict[str, Any], default: str = "") -> str:
|
||||
return normalize_risk_business_stage(
|
||||
flag.get("business_stage")
|
||||
or flag.get("businessStage")
|
||||
or flag.get("control_stage")
|
||||
or flag.get("controlStage"),
|
||||
default=default,
|
||||
)
|
||||
|
||||
|
||||
def infer_risk_domain(flag: dict[str, Any]) -> str:
|
||||
explicit_domain = (
|
||||
flag.get("risk_domain")
|
||||
or flag.get("riskDomain")
|
||||
or flag.get("domain")
|
||||
)
|
||||
if explicit_domain:
|
||||
return normalize_risk_domain(explicit_domain)
|
||||
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip().lower()
|
||||
if source == "budget_control" or "budget" in event_type:
|
||||
return "budget"
|
||||
if source in {"manual_return", "approval_routing"}:
|
||||
return "workflow"
|
||||
if source in {"attachment_analysis"}:
|
||||
return "invoice"
|
||||
if source in {"financial_risk_graph"}:
|
||||
return "profile"
|
||||
|
||||
corpus = " ".join(
|
||||
str(value or "")
|
||||
for value in [
|
||||
flag.get("rule_code"),
|
||||
flag.get("risk_category"),
|
||||
flag.get("ontology_signal"),
|
||||
flag.get("label"),
|
||||
flag.get("name"),
|
||||
flag.get("title"),
|
||||
flag.get("message"),
|
||||
flag.get("summary"),
|
||||
flag.get("description"),
|
||||
]
|
||||
).lower()
|
||||
if any(token in corpus for token in ["预算", "budget"]):
|
||||
return "budget"
|
||||
if any(token in corpus for token in ["发票", "票据", "单据", "附件", "ocr", "invoice", "receipt"]):
|
||||
return "invoice"
|
||||
trip_tokens = [
|
||||
"行程",
|
||||
"城市",
|
||||
"住宿",
|
||||
"交通",
|
||||
"差旅",
|
||||
"酒店",
|
||||
"日期",
|
||||
"时间",
|
||||
"trip",
|
||||
"travel",
|
||||
"city",
|
||||
"hotel",
|
||||
"transport",
|
||||
"period",
|
||||
]
|
||||
if any(token in corpus for token in trip_tokens):
|
||||
return "trip"
|
||||
if any(token in corpus for token in ["金额", "超标", "阈值", "额度", "标准", "amount", "limit", "over"]):
|
||||
return "amount"
|
||||
if any(token in corpus for token in ["历史", "画像", "异常关系", "profile", "baseline"]):
|
||||
return "profile"
|
||||
if any(token in corpus for token in ["审批", "退回", "流程", "付款", "routing", "approval", "return", "payment"]):
|
||||
return "workflow"
|
||||
return "policy"
|
||||
|
||||
|
||||
def infer_risk_semantics(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> tuple[str, str, str]:
|
||||
risk_domain = infer_risk_domain(flag)
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
|
||||
if source in _NON_RISK_SYSTEM_SOURCES:
|
||||
return risk_domain, "admin", "system_trace"
|
||||
if risk_domain == "budget":
|
||||
return risk_domain, "budget_manager", "budget_governance"
|
||||
if source == "attachment_analysis":
|
||||
return risk_domain, "submitter", "fixable_by_submitter"
|
||||
if risk_domain == "profile":
|
||||
return risk_domain, "leader", "review_decision"
|
||||
if business_stage == REIMBURSEMENT_BUSINESS_STAGE:
|
||||
if risk_domain in {"policy", "invoice", "trip", "amount"}:
|
||||
return risk_domain, "submitter", "fixable_by_submitter"
|
||||
return risk_domain, "finance", "finance_check"
|
||||
if business_stage == EXPENSE_APPLICATION_BUSINESS_STAGE:
|
||||
return risk_domain, "leader", "review_decision"
|
||||
return risk_domain, "leader", "review_decision"
|
||||
|
||||
|
||||
def enrich_risk_flag_semantics(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
business_stage: str | None = None,
|
||||
risk_domain: str | None = None,
|
||||
visibility_scope: str | None = None,
|
||||
actionability: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stage = normalize_risk_business_stage(
|
||||
business_stage
|
||||
or flag.get("business_stage")
|
||||
or flag.get("businessStage")
|
||||
or flag.get("control_stage")
|
||||
or flag.get("controlStage")
|
||||
)
|
||||
inferred_domain, inferred_scope, inferred_actionability = infer_risk_semantics(
|
||||
flag,
|
||||
business_stage=stage,
|
||||
)
|
||||
domain = normalize_risk_domain(risk_domain or flag.get("risk_domain") or flag.get("riskDomain"), inferred_domain)
|
||||
scope = normalize_risk_visibility_scope(
|
||||
visibility_scope or flag.get("visibility_scope") or flag.get("visibilityScope"),
|
||||
inferred_scope,
|
||||
)
|
||||
action = normalize_risk_actionability(
|
||||
actionability or flag.get("actionability"),
|
||||
inferred_actionability,
|
||||
)
|
||||
|
||||
return {
|
||||
**flag,
|
||||
"business_stage": stage,
|
||||
"businessStage": stage,
|
||||
"risk_domain": domain,
|
||||
"visibility_scope": scope,
|
||||
"actionability": action,
|
||||
}
|
||||
|
||||
|
||||
def with_risk_business_stage(
|
||||
flag: dict[str, Any],
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
return enrich_risk_flag_semantics(flag, business_stage=business_stage)
|
||||
@@ -36,6 +36,7 @@ from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
@@ -49,8 +50,10 @@ from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
||||
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
||||
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_constants import (
|
||||
EXPENSE_TYPE_LABELS,
|
||||
@@ -131,7 +134,9 @@ from app.services.ocr import OcrService
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
@@ -197,7 +202,7 @@ class ExpenseClaimService(
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
@@ -210,7 +215,7 @@ class ExpenseClaimService(
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
@@ -236,7 +241,7 @@ class ExpenseClaimService(
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
return self.db.scalar(stmt)
|
||||
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
@@ -468,27 +473,44 @@ class ExpenseClaimService(
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"}
|
||||
and str(flag.get("source") or "").strip()
|
||||
in {"submission_review", "attachment_analysis"}
|
||||
)
|
||||
]
|
||||
submit_flag = {
|
||||
"source": "application_submission",
|
||||
"event_type": "expense_application_submission",
|
||||
"severity": "info",
|
||||
"label": "申请提交",
|
||||
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||
"next_status": "submitted",
|
||||
"next_approval_stage": "直属领导审批",
|
||||
"created_at": submitted_at.isoformat(),
|
||||
}
|
||||
platform_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
platform_flags = list(platform_review.get("flags") or [])
|
||||
submit_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "application_submission",
|
||||
"event_type": "expense_application_submission",
|
||||
"severity": "info",
|
||||
"label": "申请提交",
|
||||
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||
"next_status": "submitted",
|
||||
"next_approval_stage": "直属领导审批",
|
||||
"created_at": submitted_at.isoformat(),
|
||||
},
|
||||
"expense_application",
|
||||
)
|
||||
claim.status = "submitted"
|
||||
claim.approval_stage = "直属领导审批"
|
||||
claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*preserved_flags, submit_flag, *platform_flags],
|
||||
budget_flags,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
claim.submitted_at = submitted_at
|
||||
else:
|
||||
claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
claim.risk_flags_json,
|
||||
budget_flags,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
|
||||
claim.status = str(review_result.get("status") or "supplement")
|
||||
@@ -681,6 +703,7 @@ class ExpenseClaimService(
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage="expense_application" if is_application_claim else "reimbursement",
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
@@ -717,10 +740,6 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
402
server/src/app/services/hermes_risk_clue_collector.py
Normal file
402
server/src/app/services/hermes_risk_clue_collector.py
Normal file
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
class HermesRiskClueCollectorService:
|
||||
"""归集待人工复核线索,不生成、不改写、不发布规则。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def collect_risk_clues(
|
||||
self,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
RiskObservationService(self.db).ensure_storage_ready()
|
||||
safe_limit = max(1, min(int(limit or 100), 200))
|
||||
claims = self._fetch_recent_claims(safe_limit)
|
||||
observations = self._fetch_recent_observations(safe_limit * 2)
|
||||
feedback_items = self._fetch_recent_feedback(safe_limit)
|
||||
|
||||
facts = [self._claim_fact(claim) for claim in claims]
|
||||
claim_rule_hits = self._claim_rule_hits(claims)
|
||||
observation_rule_hits = self._observation_rule_hits(observations)
|
||||
rule_hits = self._dedupe_by_id([*observation_rule_hits, *claim_rule_hits])
|
||||
evidence_refs = self._evidence_refs(observations, claim_rule_hits)
|
||||
risk_clues = self._risk_clues(
|
||||
observations=observations,
|
||||
claim_rule_hits=claim_rule_hits,
|
||||
evidence_refs=evidence_refs,
|
||||
)
|
||||
feedback_summary = self._feedback_summary(feedback_items)
|
||||
|
||||
message = (
|
||||
"风险线索归集完成:"
|
||||
f"读取 {len(facts)} 条申请/报销事实,"
|
||||
f"整理 {len(rule_hits)} 条规则命中,"
|
||||
f"输出 {len(risk_clues)} 条待人工复核线索。"
|
||||
)
|
||||
return {
|
||||
"message": message,
|
||||
"task_type": "risk_clue_collect",
|
||||
"output_format": "risk_clue_review_packet",
|
||||
"run_id": run_id,
|
||||
"fact_count": len(facts),
|
||||
"rule_hit_count": len(rule_hits),
|
||||
"risk_clue_count": len(risk_clues),
|
||||
"evidence_ref_count": len(evidence_refs),
|
||||
"facts": facts,
|
||||
"rule_hits": rule_hits,
|
||||
"risk_clues": risk_clues,
|
||||
"evidence_refs": evidence_refs,
|
||||
"feedback_summary": feedback_summary,
|
||||
"human_review_required": True,
|
||||
"writes_rules": False,
|
||||
"role_boundary": (
|
||||
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
||||
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
||||
),
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
def _fetch_recent_claims(self, limit: int) -> list[ExpenseClaim]:
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.desc()).limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_recent_observations(self, limit: int) -> list[RiskObservation]:
|
||||
stmt = (
|
||||
select(RiskObservation)
|
||||
.options(selectinload(RiskObservation.feedback_items))
|
||||
.order_by(RiskObservation.risk_score.desc(), RiskObservation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_recent_feedback(self, limit: int) -> list[RiskObservationFeedback]:
|
||||
stmt = (
|
||||
select(RiskObservationFeedback)
|
||||
.options(selectinload(RiskObservationFeedback.observation))
|
||||
.order_by(RiskObservationFeedback.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _claim_fact(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
return {
|
||||
"fact_id": f"fact:claim:{claim.id}",
|
||||
"source": "expense_claims",
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"claim_kind": "application" if is_application_claim_no(claim.claim_no) else "reimbursement",
|
||||
"employee_name": claim.employee_name,
|
||||
"department_name": claim.department_name,
|
||||
"expense_type": claim.expense_type,
|
||||
"amount": _decimal_to_float(claim.amount),
|
||||
"currency": claim.currency,
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"occurred_at": _isoformat(claim.occurred_at),
|
||||
"submitted_at": _isoformat(claim.submitted_at),
|
||||
"risk_flag_count": len(list(claim.risk_flags_json or [])),
|
||||
}
|
||||
|
||||
def _claim_rule_hits(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
hits: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
for index, flag in enumerate(list(claim.risk_flags_json or [])):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
signal = _text(
|
||||
flag.get("risk_signal")
|
||||
or flag.get("risk_type")
|
||||
or flag.get("rule_code")
|
||||
or flag.get("code")
|
||||
or flag.get("label")
|
||||
)
|
||||
if not signal:
|
||||
continue
|
||||
rule_code = _text(flag.get("rule_code") or flag.get("code") or signal)
|
||||
hits.append(
|
||||
{
|
||||
"hit_id": f"rule_hit:claim:{claim.id}:{rule_code}:{index}",
|
||||
"source": _text(flag.get("source")) or "claim_risk_flags",
|
||||
"rule_code": rule_code,
|
||||
"risk_signal": signal,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"title": _text(flag.get("label") or flag.get("title")) or signal,
|
||||
"message": _text(flag.get("message") or flag.get("summary") or flag.get("reason")),
|
||||
"severity": _text(flag.get("severity") or flag.get("risk_level")),
|
||||
"metadata": flag,
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def _observation_rule_hits(self, observations: list[RiskObservation]) -> list[dict[str, Any]]:
|
||||
hits: list[dict[str, Any]] = []
|
||||
for observation in observations:
|
||||
if not _is_rule_hit_observation(observation):
|
||||
continue
|
||||
rule_code = _text(
|
||||
(observation.decision_trace_json or {}).get("rule_code")
|
||||
or (observation.policy_refs_json or [""])[0]
|
||||
or observation.risk_signal
|
||||
)
|
||||
hits.append(
|
||||
{
|
||||
"hit_id": f"rule_hit:observation:{observation.observation_key}",
|
||||
"source": observation.source or "risk_observation",
|
||||
"rule_code": rule_code,
|
||||
"risk_signal": observation.risk_signal,
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"title": observation.title,
|
||||
"message": observation.description,
|
||||
"severity": observation.risk_level,
|
||||
"observation_key": observation.observation_key,
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def _evidence_refs(
|
||||
self,
|
||||
observations: list[RiskObservation],
|
||||
claim_rule_hits: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
refs: list[dict[str, Any]] = []
|
||||
for observation in observations:
|
||||
for index, evidence in enumerate(list(observation.evidence_json or [])):
|
||||
if not isinstance(evidence, dict):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"evidence_id": f"evidence:observation:{observation.observation_key}:{index}",
|
||||
"source": _text(evidence.get("source")) or observation.source or "risk_observation",
|
||||
"title": _text(evidence.get("title") or evidence.get("code")) or observation.title,
|
||||
"detail": _text(
|
||||
evidence.get("detail")
|
||||
or evidence.get("message")
|
||||
or evidence.get("summary")
|
||||
),
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"observation_key": observation.observation_key,
|
||||
}
|
||||
)
|
||||
for hit in claim_rule_hits:
|
||||
refs.append(
|
||||
{
|
||||
"evidence_id": f"evidence:{hit['hit_id']}",
|
||||
"source": hit["source"],
|
||||
"title": hit["title"],
|
||||
"detail": hit["message"] or "单据风险标记记录了该规则命中。",
|
||||
"claim_id": hit["claim_id"],
|
||||
"claim_no": hit["claim_no"],
|
||||
"rule_hit_id": hit["hit_id"],
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
def _risk_clues(
|
||||
self,
|
||||
*,
|
||||
observations: list[RiskObservation],
|
||||
claim_rule_hits: list[dict[str, Any]],
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
clues = [
|
||||
self._observation_clue(observation, evidence_refs)
|
||||
for observation in observations
|
||||
if _needs_human_review(observation)
|
||||
]
|
||||
observed_claim_signals = {
|
||||
(clue.get("claim_id"), clue.get("risk_signal"))
|
||||
for clue in clues
|
||||
if clue.get("claim_id") and clue.get("risk_signal")
|
||||
}
|
||||
for hit in claim_rule_hits:
|
||||
key = (hit.get("claim_id"), hit.get("risk_signal"))
|
||||
if key in observed_claim_signals:
|
||||
continue
|
||||
clues.append(self._claim_flag_clue(hit, evidence_refs))
|
||||
clues.sort(key=lambda item: float(item.get("confidence_score") or 0), reverse=True)
|
||||
return clues[:30]
|
||||
|
||||
def _observation_clue(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
evidence_ids = [
|
||||
item["evidence_id"]
|
||||
for item in evidence_refs
|
||||
if item.get("observation_key") == observation.observation_key
|
||||
]
|
||||
confidence = _confidence(observation.confidence_score, observation.risk_score)
|
||||
return {
|
||||
"clue_id": f"risk_clue:observation:{observation.observation_key}",
|
||||
"source": "risk_observation",
|
||||
"status": "human_review_required",
|
||||
"observation_key": observation.observation_key,
|
||||
"feedback_status": observation.feedback_status,
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"subject_type": observation.subject_type,
|
||||
"subject_key": observation.subject_key,
|
||||
"risk_signal": observation.risk_signal,
|
||||
"risk_level": observation.risk_level,
|
||||
"title": observation.title or observation.risk_signal,
|
||||
"summary": observation.description
|
||||
or f"{observation.claim_no or observation.subject_label} 存在待复核线索。",
|
||||
"confidence_score": confidence,
|
||||
"evidence_refs": evidence_ids,
|
||||
"rule_hits": [
|
||||
f"rule_hit:observation:{observation.observation_key}"
|
||||
]
|
||||
if _is_rule_hit_observation(observation)
|
||||
else [],
|
||||
"fact_refs": [f"fact:claim:{observation.claim_id}"] if observation.claim_id else [],
|
||||
"review_reason": _review_reason(observation),
|
||||
"next_action": "人工复核事实、规则命中和证据来源。",
|
||||
"not_final_conclusion": True,
|
||||
}
|
||||
|
||||
def _claim_flag_clue(
|
||||
self,
|
||||
hit: dict[str, Any],
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
evidence_ids = [
|
||||
item["evidence_id"]
|
||||
for item in evidence_refs
|
||||
if item.get("rule_hit_id") == hit.get("hit_id")
|
||||
]
|
||||
return {
|
||||
"clue_id": f"risk_clue:{hit['hit_id']}",
|
||||
"source": "claim_risk_flags",
|
||||
"status": "human_review_required",
|
||||
"observation_key": "",
|
||||
"feedback_status": "unreviewed",
|
||||
"claim_id": hit.get("claim_id"),
|
||||
"claim_no": hit.get("claim_no"),
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": f"claim:{hit.get('claim_id')}",
|
||||
"risk_signal": hit.get("risk_signal"),
|
||||
"risk_level": hit.get("severity") or "medium",
|
||||
"title": hit.get("title") or hit.get("risk_signal"),
|
||||
"summary": hit.get("message") or "单据存在规则命中,需要人工复核事实与制度依据。",
|
||||
"confidence_score": 0.72,
|
||||
"evidence_refs": evidence_ids,
|
||||
"rule_hits": [hit["hit_id"]],
|
||||
"fact_refs": [f"fact:claim:{hit.get('claim_id')}"] if hit.get("claim_id") else [],
|
||||
"review_reason": "规则命中尚未形成已确认处置结论。",
|
||||
"next_action": "人工复核该规则命中是否需要补充风险观察或处置反馈。",
|
||||
"not_final_conclusion": True,
|
||||
}
|
||||
|
||||
def _feedback_summary(self, feedback_items: list[RiskObservationFeedback]) -> dict[str, Any]:
|
||||
counts = Counter(item.feedback_type for item in feedback_items)
|
||||
return {
|
||||
"total": len(feedback_items),
|
||||
"by_type": dict(counts),
|
||||
"recent": [
|
||||
{
|
||||
"feedback_id": item.id,
|
||||
"feedback_type": item.feedback_type,
|
||||
"action": item.action,
|
||||
"actor": item.actor,
|
||||
"observation_key": item.observation.observation_key if item.observation else "",
|
||||
"created_at": _isoformat(item.created_at),
|
||||
}
|
||||
for item in feedback_items[:10]
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_by_id(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
deduped: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
key = _text(item.get("hit_id"))
|
||||
if key and key not in deduped:
|
||||
deduped[key] = item
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _is_rule_hit_observation(observation: RiskObservation) -> bool:
|
||||
if _text(observation.source) == "rule_center":
|
||||
return True
|
||||
if _number((observation.contribution_scores_json or {}).get("S_rule")) > 0:
|
||||
return True
|
||||
for evidence in list(observation.evidence_json or []):
|
||||
if isinstance(evidence, dict) and _text(evidence.get("source")) == "rule_center":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_human_review(observation: RiskObservation) -> bool:
|
||||
status = _text(observation.status)
|
||||
feedback_status = _text(observation.feedback_status)
|
||||
if status in {"confirmed", "false_positive", "ignored", "resolved"}:
|
||||
return False
|
||||
if feedback_status in {"confirmed", "false_positive", "ignored", "resolved"}:
|
||||
return False
|
||||
return observation.risk_score >= 50 or observation.risk_level in {"medium", "high", "critical"}
|
||||
|
||||
|
||||
def _review_reason(observation: RiskObservation) -> str:
|
||||
if not observation.feedback_items:
|
||||
return "尚未记录人工复核反馈。"
|
||||
latest = observation.feedback_items[0]
|
||||
return latest.comment or f"最近反馈类型:{latest.feedback_type},仍需人工复核。"
|
||||
|
||||
|
||||
def _confidence(value: float | None, score: int) -> float:
|
||||
try:
|
||||
parsed = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
parsed = max(0.35, min(0.92, float(score or 0) / 100))
|
||||
return round(parsed, 2)
|
||||
|
||||
|
||||
def _decimal_to_float(value: Decimal | int | float | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
|
||||
def _number(value: object) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str:
|
||||
return value.isoformat() if value is not None else ""
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -13,6 +13,7 @@ from app.algorithem.risk_graph import (
|
||||
from app.core.logging import get_logger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.hermes_risk_scanner")
|
||||
@@ -110,15 +111,18 @@ class HermesRiskScannerService:
|
||||
@staticmethod
|
||||
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
|
||||
existing = list(claim.risk_flags_json or [])
|
||||
flag = {
|
||||
"source": "financial_risk_graph",
|
||||
"risk_signal": observation.get("risk_signal"),
|
||||
"severity": observation.get("risk_level"),
|
||||
"risk_score": observation.get("risk_score"),
|
||||
"confidence_score": observation.get("confidence_score"),
|
||||
"algorithm_version": observation.get("algorithm_version"),
|
||||
"observation_key": observation.get("observation_key"),
|
||||
}
|
||||
flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "financial_risk_graph",
|
||||
"risk_signal": observation.get("risk_signal"),
|
||||
"severity": observation.get("risk_level"),
|
||||
"risk_score": observation.get("risk_score"),
|
||||
"confidence_score": observation.get("confidence_score"),
|
||||
"algorithm_version": observation.get("algorithm_version"),
|
||||
"observation_key": observation.get("observation_key"),
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and item.get("observation_key") == flag["observation_key"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.db.session import get_session_factory
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_expense_report import HermesExpenseReportService
|
||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
|
||||
logger = get_logger("app.services.hermes_scheduler")
|
||||
@@ -168,6 +169,14 @@ class HermesScheduler:
|
||||
f"生成 {summary.get('snapshot_count', 0)} 条快照,"
|
||||
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
|
||||
)
|
||||
elif config.task_type == "risk_clue_collect":
|
||||
collector = HermesRiskClueCollectorService(db)
|
||||
summary = collector.collect_risk_clues(run_id=log_record.id)
|
||||
log_record.result_summary = (
|
||||
f"风险线索归集完成:读取 {summary.get('fact_count', 0)} 条事实,"
|
||||
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
|
||||
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
|
||||
)
|
||||
|
||||
log_record.status = "success"
|
||||
log_record.completed_at = datetime.now(UTC)
|
||||
|
||||
@@ -25,9 +25,11 @@ from app.schemas.orchestrator import (
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
from app.services.knowledge import KnowledgeService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
||||
@@ -57,6 +59,7 @@ class OrchestratorService:
|
||||
self.expense_claim_service = ExpenseClaimService(db)
|
||||
self.knowledge_service = KnowledgeService(db=db)
|
||||
self.run_service = AgentRunService(db)
|
||||
self.trace_service = AgentTraceService(db)
|
||||
self.ontology_service = SemanticOntologyService(db)
|
||||
self.user_agent_service = UserAgentService(db)
|
||||
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
|
||||
@@ -67,11 +70,15 @@ class OrchestratorService:
|
||||
knowledge_service=self.knowledge_service,
|
||||
user_agent_service=self.user_agent_service,
|
||||
database_query_builder=self.database_query_builder,
|
||||
trace_service=self.trace_service,
|
||||
)
|
||||
|
||||
def run(self, payload: OrchestratorRequest) -> OrchestratorResponse:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
context_json = dict(payload.context_json or {})
|
||||
context_json = self._hydrate_user_context(
|
||||
user_id=payload.user_id,
|
||||
context_json=dict(payload.context_json or {}),
|
||||
)
|
||||
conversation_id = str(payload.conversation_id or "").strip() or None
|
||||
conversation = None
|
||||
if payload.source == AgentRunSource.USER_MESSAGE.value:
|
||||
@@ -87,6 +94,9 @@ class OrchestratorService:
|
||||
context_json=context_json,
|
||||
message=payload.message,
|
||||
)
|
||||
context_json["conversation_id"] = conversation_id
|
||||
elif conversation_id:
|
||||
context_json["conversation_id"] = conversation_id
|
||||
|
||||
route_json: dict[str, Any] = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -105,6 +115,25 @@ class OrchestratorService:
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
result_summary="Orchestrator 已接收请求。",
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="orchestrator",
|
||||
event_name="request_received",
|
||||
title="接收用户请求",
|
||||
summary=str(payload.message or payload.task_id or payload.source or "").strip(),
|
||||
input_json={
|
||||
"source": payload.source,
|
||||
"user_id": payload.user_id,
|
||||
"conversation_id": conversation_id,
|
||||
"task_id": payload.task_id,
|
||||
"message": payload.message,
|
||||
},
|
||||
output_json={
|
||||
"run_id": run.run_id,
|
||||
"context_keys": sorted(context_json.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
message, task_asset = self._resolve_message(payload)
|
||||
@@ -120,6 +149,19 @@ class OrchestratorService:
|
||||
"ocr_summary": context_json.get("ocr_summary", ""),
|
||||
},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation.conversation_id,
|
||||
stage="conversation",
|
||||
event_name="conversation_hydrated",
|
||||
title="会话上下文补全",
|
||||
summary=f"会话 {conversation.conversation_id} 已写入用户消息。",
|
||||
input_json={"message": message},
|
||||
output_json={
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"context_keys": sorted(context_json.keys()),
|
||||
},
|
||||
)
|
||||
ontology = self.ontology_service.parse_for_run(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
@@ -128,6 +170,16 @@ class OrchestratorService:
|
||||
),
|
||||
run_id=run.run_id,
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="semantic",
|
||||
event_name="semantic_parsed",
|
||||
title="语义识别完成",
|
||||
summary=f"{ontology.scenario} / {ontology.intent}",
|
||||
input_json={"query": message, "context_keys": sorted(context_json.keys())},
|
||||
output_json=self.execution_engine._build_ontology_json(ontology),
|
||||
)
|
||||
if context_json.get("simulate_orchestrator_exception"):
|
||||
raise RuntimeError("simulated orchestrator exception")
|
||||
selected_agent, route_reason = self._select_agent(payload, ontology)
|
||||
@@ -144,6 +196,25 @@ class OrchestratorService:
|
||||
and not is_expense_review_action
|
||||
and not is_expense_application_context
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="route",
|
||||
event_name="route_resolved",
|
||||
title="路由与能力选择",
|
||||
summary=route_reason,
|
||||
input_json={
|
||||
"scenario": ontology.scenario,
|
||||
"intent": ontology.intent,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
output_json={
|
||||
"selected_agent": selected_agent,
|
||||
"route_reason": route_reason,
|
||||
"selected_capability_codes": selected_capability_codes,
|
||||
"requires_confirmation": requires_confirmation,
|
||||
},
|
||||
)
|
||||
|
||||
route_json = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -337,6 +408,32 @@ class OrchestratorService:
|
||||
},
|
||||
},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="conversation",
|
||||
event_name="conversation_updated",
|
||||
title="会话状态写回",
|
||||
summary="助手回复与会话状态已写回。",
|
||||
input_json={"draft_payload_present": isinstance(draft_payload, dict)},
|
||||
output_json={"status": final_status, "message": result_message},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="response",
|
||||
event_name="response_built",
|
||||
title="生成最终回复",
|
||||
status=final_status,
|
||||
summary=result_message,
|
||||
input_json={"outcome_status": outcome.status},
|
||||
output_json={
|
||||
"status": response_status,
|
||||
"requires_confirmation": response_requires_confirmation,
|
||||
"trace_summary": trace_summary.model_dump(),
|
||||
"result": outcome.result,
|
||||
},
|
||||
)
|
||||
return OrchestratorResponse(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
@@ -350,6 +447,17 @@ class OrchestratorService:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Orchestrator run failed run_id=%s", run.run_id)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="orchestrator",
|
||||
event_name="failed",
|
||||
title="Orchestrator 执行失败",
|
||||
status="failed",
|
||||
summary=str(exc),
|
||||
output_json={"route_json": route_json},
|
||||
error_message=str(exc),
|
||||
)
|
||||
self.run_service.update_run(
|
||||
run.run_id,
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
@@ -394,6 +502,40 @@ class OrchestratorService:
|
||||
),
|
||||
)
|
||||
|
||||
def _record_trace_event(self, **kwargs: Any) -> None:
|
||||
self.trace_service.record_event_safe(**kwargs)
|
||||
|
||||
def _hydrate_user_context(self, user_id: str | None, context_json: dict[str, Any]) -> dict[str, Any]:
|
||||
identifier = str(user_id or context_json.get("username") or context_json.get("email") or "").strip()
|
||||
if not identifier:
|
||||
return context_json
|
||||
|
||||
snapshot = AuthService(self.db).get_user_snapshot(identifier)
|
||||
if snapshot is None:
|
||||
return context_json
|
||||
|
||||
values = {
|
||||
"name": snapshot.name,
|
||||
"department": snapshot.department,
|
||||
"department_name": snapshot.departmentName or snapshot.department,
|
||||
"position": snapshot.position,
|
||||
"grade": snapshot.grade,
|
||||
"employee_no": snapshot.employeeNo,
|
||||
"manager_name": snapshot.managerName,
|
||||
"employee_location": snapshot.location,
|
||||
"cost_center": snapshot.costCenter,
|
||||
"finance_owner_name": snapshot.financeOwnerName,
|
||||
"employee_risk_profile": snapshot.riskProfile,
|
||||
"role_codes": snapshot.roleCodes,
|
||||
"is_admin": snapshot.isAdmin,
|
||||
}
|
||||
|
||||
for key, value in values.items():
|
||||
if context_json.get(key) in (None, "", [], {}):
|
||||
context_json[key] = value
|
||||
|
||||
return context_json
|
||||
|
||||
def _resolve_message(
|
||||
self,
|
||||
payload: OrchestratorRequest,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.knowledge_sync import KnowledgeSyncDispatchService
|
||||
|
||||
@@ -36,6 +37,7 @@ class OrchestratorExecutionEngine:
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
@@ -43,6 +45,7 @@ class OrchestratorExecutionEngine:
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
@@ -383,6 +386,8 @@ class OrchestratorExecutionEngine:
|
||||
task_asset=task_asset,
|
||||
context_json=context_json,
|
||||
)
|
||||
if task_type == "risk_clue_collect":
|
||||
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
|
||||
return None
|
||||
|
||||
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
|
||||
@@ -502,6 +507,41 @@ class OrchestratorExecutionEngine:
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
def _execute_risk_clue_collect(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
summary, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="digital_employee.risk_clue.collect",
|
||||
request_json={"task_type": "risk_clue_collect"},
|
||||
context_json=context_json,
|
||||
executor=lambda: HermesRiskClueCollectorService(self.db).collect_risk_clues(
|
||||
run_id=run_id
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"风险线索归集失败,已保留失败记录:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
message = (
|
||||
str(summary.get("message") or "").strip()
|
||||
or "风险线索归集完成:"
|
||||
f"读取 {summary.get('fact_count', 0)} 条事实,"
|
||||
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
|
||||
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result={"message": message, "report_type": "risk_clue_collect", "summary": summary, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
||||
if task_asset is None:
|
||||
@@ -613,6 +653,11 @@ class OrchestratorExecutionEngine:
|
||||
status="succeeded",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
if self.trace_service:
|
||||
self.trace_service.record_tool_event_safe(
|
||||
run_id, tool_type, tool_name, request_json, response,
|
||||
"succeeded", duration_ms, context_json,
|
||||
)
|
||||
return response, False
|
||||
except Exception as exc:
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
@@ -627,6 +672,11 @@ class OrchestratorExecutionEngine:
|
||||
duration_ms=duration_ms,
|
||||
error_message=str(exc),
|
||||
)
|
||||
if self.trace_service:
|
||||
self.trace_service.record_tool_event_safe(
|
||||
run_id, tool_type, tool_name, request_json, response,
|
||||
"failed", duration_ms, context_json, str(exc),
|
||||
)
|
||||
return response, True
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.schemas.risk_observation import (
|
||||
RiskObservationDashboardRead,
|
||||
RiskObservationFeedbackCreate,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
|
||||
|
||||
HIGH_LEVELS = {"high", "critical"}
|
||||
SEVERITY_SCORE = {
|
||||
@@ -122,6 +123,7 @@ class RiskObservationService:
|
||||
severity = _normalize_level(flag.get("severity"))
|
||||
score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"])
|
||||
rule_code = _text(flag.get("rule_code"))
|
||||
business_stage = normalize_risk_business_stage(flag.get("business_stage"))
|
||||
observation_key = (
|
||||
f"risk:{claim.id}:platform:{rule_code or signal}"
|
||||
)
|
||||
@@ -141,7 +143,7 @@ class RiskObservationService:
|
||||
"risk_score": score,
|
||||
"risk_level": severity,
|
||||
"confidence_score": "0.78",
|
||||
"control_stage": "reimbursement",
|
||||
"control_stage": business_stage,
|
||||
"control_mode": "risk_observation",
|
||||
"automation_mode": (
|
||||
"semi_auto_review"
|
||||
@@ -333,6 +335,14 @@ class RiskObservationService:
|
||||
confirmed = sum(1 for item in observations if item.feedback_status == "confirmed")
|
||||
false_positive = sum(1 for item in observations if item.feedback_status == "false_positive")
|
||||
pending = sum(1 for item in observations if item.status == "pending_review")
|
||||
feedback_samples = int(
|
||||
self.db.scalar(
|
||||
select(func.count())
|
||||
.select_from(RiskObservationFeedback)
|
||||
.where(RiskObservationFeedback.created_at >= since)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
high_or_above = sum(1 for item in observations if item.risk_level in HIGH_LEVELS)
|
||||
score_sum = sum(int(item.risk_score or 0) for item in observations)
|
||||
reviewed = confirmed + false_positive
|
||||
@@ -343,9 +353,11 @@ class RiskObservationService:
|
||||
window_days=window_days,
|
||||
total_observations=total,
|
||||
pending_count=pending,
|
||||
risk_clue_count=pending,
|
||||
high_or_above_count=high_or_above,
|
||||
confirmed_count=confirmed,
|
||||
false_positive_count=false_positive,
|
||||
feedback_sample_count=feedback_samples,
|
||||
total_amount=float(total_amount),
|
||||
average_score=round(score_sum / total, 2) if total else 0.0,
|
||||
level_distribution=_count_by(observations, "risk_level"),
|
||||
|
||||
@@ -13,6 +13,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_claim_risk_stage import infer_risk_domain
|
||||
from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts
|
||||
from app.services.risk_rule_generation_ontology import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
@@ -423,6 +424,28 @@ class RiskRuleGenerationService:
|
||||
risk_level_label = str(
|
||||
risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险")
|
||||
)
|
||||
semantic_risk_domain = infer_risk_domain(
|
||||
{
|
||||
"rule_code": rule_code,
|
||||
"risk_category": risk_category,
|
||||
"name": rule_title or draft.get("name"),
|
||||
"description": self._clean_text(draft.get("description")) or natural_language,
|
||||
}
|
||||
)
|
||||
semantic_visibility_scope = (
|
||||
"budget_manager"
|
||||
if semantic_risk_domain == "budget"
|
||||
else "leader"
|
||||
if business_stage == "expense_application"
|
||||
else "submitter"
|
||||
)
|
||||
semantic_actionability = (
|
||||
"budget_governance"
|
||||
if semantic_risk_domain == "budget"
|
||||
else "review_decision"
|
||||
if business_stage == "expense_application"
|
||||
else "fixable_by_submitter"
|
||||
)
|
||||
keywords = list(draft.get("keywords") or [])
|
||||
field_by_key = {item.key: item for item in fields}
|
||||
params: dict[str, Any] = {
|
||||
@@ -432,6 +455,9 @@ class RiskRuleGenerationService:
|
||||
"natural_language": natural_language,
|
||||
"business_stage": business_stage,
|
||||
"business_stage_label": business_stage_label,
|
||||
"risk_domain": semantic_risk_domain,
|
||||
"visibility_scope": semantic_visibility_scope,
|
||||
"actionability": semantic_actionability,
|
||||
}
|
||||
semantic_type = str(draft.get("semantic_type") or "").strip()
|
||||
if semantic_type:
|
||||
@@ -508,6 +534,9 @@ class RiskRuleGenerationService:
|
||||
"risk_score": risk_score_value,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": risk_level_label,
|
||||
"risk_domain": semantic_risk_domain,
|
||||
"visibility_scope": semantic_visibility_scope,
|
||||
"actionability": semantic_actionability,
|
||||
"risk_score_model": risk_score_payload.get("model"),
|
||||
"risk_score_detail": risk_score_payload,
|
||||
"rule_title": rule_title,
|
||||
@@ -519,7 +548,11 @@ class RiskRuleGenerationService:
|
||||
"business_explanation": self._clean_text(draft.get("description")),
|
||||
"condition_summary": condition_summary,
|
||||
"rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {},
|
||||
"model_semantic_plan": draft.get("model_semantic_plan") if isinstance(draft.get("model_semantic_plan"), dict) else {},
|
||||
"model_semantic_plan": (
|
||||
draft.get("model_semantic_plan")
|
||||
if isinstance(draft.get("model_semantic_plan"), dict)
|
||||
else {}
|
||||
),
|
||||
"flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ class RiskRuleGenerationJobService:
|
||||
"generation_status": AgentAssetStatus.FAILED.value,
|
||||
"generation_error": error_message[:1000],
|
||||
"generation_failed_at": datetime.now(UTC).isoformat(),
|
||||
"last_operation": {
|
||||
"action": "generation_failed",
|
||||
"actor": actor,
|
||||
"at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
}
|
||||
)
|
||||
asset.status = AgentAssetStatus.FAILED.value
|
||||
|
||||
@@ -29,6 +29,7 @@ RISK_LEVEL_LABELS: dict[str, str] = {
|
||||
}
|
||||
|
||||
EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||
"all",
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
@@ -40,10 +41,22 @@ EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = (
|
||||
"welfare",
|
||||
)
|
||||
EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = {
|
||||
code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES
|
||||
"all": "全部",
|
||||
**{
|
||||
code: EXPENSE_TYPE_LABEL_BY_CODE[code]
|
||||
for code in EXPENSE_RISK_CATEGORY_CODES
|
||||
if code != "all"
|
||||
},
|
||||
}
|
||||
EXPENSE_RISK_CATEGORY_ALIASES = {
|
||||
"*": "all",
|
||||
"overall": "all",
|
||||
"general": "all",
|
||||
"全部": "all",
|
||||
"通用": "all",
|
||||
"entertainment": "meal",
|
||||
"business_meal": "meal",
|
||||
"purchase": "office",
|
||||
}
|
||||
|
||||
EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = {
|
||||
|
||||
495
server/src/app/services/risk_rule_template_catalog.py
Normal file
495
server/src/app/services/risk_rule_template_catalog.py
Normal file
@@ -0,0 +1,495 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain
|
||||
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
|
||||
from app.services.risk_rule_generation_ontology import (
|
||||
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
FIELD_ONTOLOGY,
|
||||
)
|
||||
|
||||
TEMPLATE_GROUPS: tuple[dict[str, str | int], ...] = (
|
||||
{"group": "budget", "group_label": "预算", "order": 10},
|
||||
{"group": "invoice", "group_label": "票据", "order": 20},
|
||||
{"group": "travel", "group_label": "差旅", "order": 30},
|
||||
{"group": "entertainment", "group_label": "招待", "order": 40},
|
||||
{"group": "procurement_ap", "group_label": "采购/AP", "order": 50},
|
||||
{"group": "corporate_card", "group_label": "企业卡", "order": 60},
|
||||
{"group": "general", "group_label": "通用", "order": 70},
|
||||
)
|
||||
|
||||
_GROUP_LABELS = {str(item["group"]): str(item["group_label"]) for item in TEMPLATE_GROUPS}
|
||||
_FIELD_BY_KEY = {field.key: field for field in FIELD_ONTOLOGY}
|
||||
|
||||
|
||||
def list_risk_rule_template_groups() -> list[dict[str, Any]]:
|
||||
templates = [_build_template(item) for item in _TEMPLATE_DEFINITIONS]
|
||||
groups: list[dict[str, Any]] = []
|
||||
for group in TEMPLATE_GROUPS:
|
||||
group_code = str(group["group"])
|
||||
group_templates = [item for item in templates if item["group"] == group_code]
|
||||
groups.append(
|
||||
{
|
||||
"group": group_code,
|
||||
"group_label": str(group["group_label"]),
|
||||
"order": int(group["order"]),
|
||||
"templates": group_templates,
|
||||
}
|
||||
)
|
||||
return deepcopy(groups)
|
||||
|
||||
|
||||
def list_risk_rule_templates() -> list[dict[str, Any]]:
|
||||
return [
|
||||
template
|
||||
for group in list_risk_rule_template_groups()
|
||||
for template in group["templates"]
|
||||
]
|
||||
|
||||
|
||||
def _build_template(definition: dict[str, Any]) -> dict[str, Any]:
|
||||
field_keys = list(definition["field_keys"])
|
||||
business_stage = str(definition.get("business_stage") or "reimbursement")
|
||||
expense_category = definition.get("expense_category")
|
||||
group = str(definition["group"])
|
||||
return {
|
||||
"template_id": definition["template_id"],
|
||||
"group": group,
|
||||
"group_label": _GROUP_LABELS[group],
|
||||
"title": definition["title"],
|
||||
"description": definition["description"],
|
||||
"business_domain": str(definition.get("business_domain") or AgentAssetDomain.EXPENSE.value),
|
||||
"business_stage": business_stage,
|
||||
"business_stage_label": EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销"),
|
||||
"expense_category": expense_category,
|
||||
"expense_category_label": EXPENSE_RISK_CATEGORY_LABELS.get(str(expense_category or ""), ""),
|
||||
"requires_attachment": bool(definition.get("requires_attachment")),
|
||||
"natural_language": definition["natural_language"],
|
||||
"fields": _field_rows(field_keys),
|
||||
"dsl_example": _manifest(
|
||||
field_keys=field_keys,
|
||||
conditions=definition["conditions"],
|
||||
hit_logic=definition["hit_logic"],
|
||||
summary=definition["summary"],
|
||||
message=definition["message"],
|
||||
semantic_type=definition["semantic_type"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _field_rows(field_keys: list[str]) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for key in field_keys:
|
||||
field = _FIELD_BY_KEY.get(key)
|
||||
if field is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"display": f"{field.label}[{field.key}]",
|
||||
"source": field.source,
|
||||
"type": field.field_type,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _manifest(
|
||||
*,
|
||||
field_keys: list[str],
|
||||
conditions: list[dict[str, Any]],
|
||||
hit_logic: dict[str, Any],
|
||||
summary: str,
|
||||
message: str,
|
||||
semantic_type: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"params": {
|
||||
"template_key": COMPOSITE_RULE_TEMPLATE_KEY,
|
||||
"semantic_type": semantic_type,
|
||||
"field_keys": field_keys,
|
||||
"conditions": conditions,
|
||||
"hit_logic": hit_logic,
|
||||
"condition_summary": summary,
|
||||
"message_template": message,
|
||||
"keywords": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"template_id": "budget_available_balance",
|
||||
"group": "budget",
|
||||
"title": "费用申请预算余额校验",
|
||||
"description": "申请金额超过预算可用余额时提示预算占用风险,适合费用申请阶段前置控制。",
|
||||
"business_stage": "expense_application",
|
||||
"expense_category": "all",
|
||||
"requires_attachment": False,
|
||||
"natural_language": (
|
||||
"费用申请时,先读取申请金额、预算可用余额、部门、费用类型和申请事由。"
|
||||
"若申请金额超过当前可用预算余额,且申请事由中没有预算追加、专项审批或紧急事项说明,"
|
||||
"则标记为中风险,要求补充预算审批说明后再继续流转。"
|
||||
),
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"budget.remaining_amount",
|
||||
"claim.department_name",
|
||||
"item.item_type",
|
||||
"claim.reason",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_exceeds_available_budget",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": ["claim.amount"],
|
||||
"right_fields": ["budget.remaining_amount"],
|
||||
"compare": "gt",
|
||||
},
|
||||
{
|
||||
"id": "missing_budget_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["预算追加", "专项审批", "紧急事项", "预算调整"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {"all": ["amount_exceeds_available_budget", "missing_budget_exception"]},
|
||||
"summary": "申请金额大于预算可用余额,且缺少预算追加或专项审批说明时命中。",
|
||||
"message": "申请金额超过预算可用余额,需补充预算审批说明。",
|
||||
"semantic_type": "budget_available_balance_check",
|
||||
},
|
||||
{
|
||||
"template_id": "duplicate_invoice_number",
|
||||
"group": "invoice",
|
||||
"title": "重复发票号码校验",
|
||||
"description": "识别同一发票号码在本次提交中重复出现的报销风险。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "office",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"费用报销时,先确认已上传发票或票据附件,再读取附件识别出的发票号码、明细附件编号和报销事由。"
|
||||
"若同一发票号码或同一明细附件编号在本次提交中重复出现,且报销事由没有说明拆单、补票或更正提交原因,"
|
||||
"则标记为高风险,要求删除重复票据或补充说明。"
|
||||
),
|
||||
"field_keys": ["attachment.invoice_no", "item.invoice_id", "claim.reason"],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "invoice_evidence_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.invoice_no", "item.invoice_id"],
|
||||
},
|
||||
{
|
||||
"id": "same_invoice_no_repeated",
|
||||
"operator": "duplicate_value",
|
||||
"fields": ["attachment.invoice_no", "item.invoice_id"],
|
||||
},
|
||||
{
|
||||
"id": "missing_duplicate_reason",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["拆单", "补票", "更正", "冲红"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": ["invoice_evidence_present", "same_invoice_no_repeated", "missing_duplicate_reason"]
|
||||
},
|
||||
"summary": "发票号码或明细附件编号重复,且缺少拆单、补票或更正说明时命中。",
|
||||
"message": "存在重复发票或重复附件编号,需删除重复票据或补充合理说明。",
|
||||
"semantic_type": "duplicate_invoice_check",
|
||||
},
|
||||
{
|
||||
"template_id": "travel_city_route_consistency",
|
||||
"group": "travel",
|
||||
"title": "差旅票据城市一致性校验",
|
||||
"description": "比对交通票、住宿票据城市与申报目的地,识别跨城、绕行或目的地不一致风险。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "travel",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||
"再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
|
||||
"若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系,"
|
||||
"或票据路线中出现申报目的地与员工常驻地之外的额外中转城市,"
|
||||
"且报销事由中没有说明绕行、跨城办事或临时改签原因,"
|
||||
"则标记为高风险,要求补充行程说明或退回修改。"
|
||||
),
|
||||
"field_keys": [
|
||||
"employee.location",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"attachment.route_cities",
|
||||
"attachment.hotel_city",
|
||||
"claim.reason",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "attachment_city_evidence_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.route_cities", "attachment.hotel_city"],
|
||||
},
|
||||
{
|
||||
"id": "city_outside_business_scope",
|
||||
"operator": "not_in_scope",
|
||||
"left_fields": ["attachment.route_cities", "attachment.hotel_city"],
|
||||
"right_fields": ["claim.location", "item.item_location", "employee.location"],
|
||||
},
|
||||
{
|
||||
"id": "missing_route_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["绕行", "跨城办事", "临时改签", "临时任务"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"attachment_city_evidence_present",
|
||||
"city_outside_business_scope",
|
||||
"missing_route_exception",
|
||||
]
|
||||
},
|
||||
"summary": "票据城市集合与申报行程集合无交集或出现额外中转城市,且缺少合理例外说明时命中。",
|
||||
"message": "票据城市与申报行程不一致,需补充绕行、跨城或改签说明。",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
},
|
||||
{
|
||||
"template_id": "travel_lodging_date_range",
|
||||
"group": "travel",
|
||||
"title": "差旅住宿日期范围校验",
|
||||
"description": "校验住宿票据日期是否落在差旅开始和结束日期范围内。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "travel",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"差旅住宿报销时,先确认已上传住宿发票或酒店水单;"
|
||||
"再读取报销事由、申报目的地、住宿城市、住宿开始日期、住宿结束日期、出差开始日期和出差结束日期。"
|
||||
"若住宿发生时间早于出差开始或晚于出差结束,且没有延期、改签、临时任务等说明,"
|
||||
"则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。"
|
||||
),
|
||||
"field_keys": [
|
||||
"attachment.stay_start_date",
|
||||
"attachment.stay_end_date",
|
||||
"claim.trip_start_date",
|
||||
"claim.trip_end_date",
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"claim.reason",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "lodging_date_evidence_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
|
||||
},
|
||||
{
|
||||
"id": "lodging_date_outside_trip_range",
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"],
|
||||
"range_start_fields": ["claim.trip_start_date"],
|
||||
"range_end_fields": ["claim.trip_end_date"],
|
||||
},
|
||||
{
|
||||
"id": "missing_lodging_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["延期", "改签", "临时任务", "行程变更"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"lodging_date_evidence_present",
|
||||
"lodging_date_outside_trip_range",
|
||||
"missing_lodging_exception",
|
||||
]
|
||||
},
|
||||
"summary": "住宿日期不在差旅日期范围内,且缺少延期、改签或临时任务说明时命中。",
|
||||
"message": "住宿日期超出差旅行程范围,需补充行程证明或重新提交票据。",
|
||||
"semantic_type": "lodging_date_range_consistency",
|
||||
},
|
||||
{
|
||||
"template_id": "entertainment_per_capita_limit",
|
||||
"group": "entertainment",
|
||||
"title": "招待人均金额超标校验",
|
||||
"description": "按参与人数计算人均招待金额,超过标准且缺少说明时提示风险。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "meal",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"业务招待报销时,读取申报总金额、参与人数、人均金额、报销事由和附件票据。"
|
||||
"若人均金额超过公司招待标准 500 元,且事由中没有高级审批、重要客户接待或专项审批说明,"
|
||||
"则标记为中风险,要求补充招待对象和审批依据。"
|
||||
),
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.attendee_count",
|
||||
"claim.per_capita_amount",
|
||||
"claim.reason",
|
||||
"attachment.invoice_no",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "per_capita_amount_exceeds_limit",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": ["claim.per_capita_amount"],
|
||||
"threshold": 500,
|
||||
"compare": "gt",
|
||||
},
|
||||
{
|
||||
"id": "missing_special_approval_reason",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["高级审批", "重要客户", "专项审批", "特殊接待"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": ["per_capita_amount_exceeds_limit", "missing_special_approval_reason"]
|
||||
},
|
||||
"summary": "人均金额大于招待标准阈值,且缺少合理审批说明时命中。",
|
||||
"message": "业务招待人均金额超过公司标准,需补充审批依据。",
|
||||
"semantic_type": "entertainment_per_capita_limit_check",
|
||||
},
|
||||
{
|
||||
"template_id": "procurement_goods_category_mismatch",
|
||||
"group": "procurement_ap",
|
||||
"title": "采购票据品名与费用类型一致性校验",
|
||||
"description": "检查发票商品服务名称是否与费用类型、采购用途一致。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "office",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"采购类费用报销时,先确认已上传发票或采购票据;"
|
||||
"再读取商品服务名称、费用类型、明细事由、申报金额和报销事由。"
|
||||
"若发票商品服务名称与费用类型或明细事由无法形成一致关系,"
|
||||
"且报销事由没有说明代采、项目采购或费用归集原因,"
|
||||
"则标记为中风险,要求补充采购用途或更换正确费用类型。"
|
||||
),
|
||||
"field_keys": [
|
||||
"attachment.goods_name",
|
||||
"item.item_type",
|
||||
"item.item_reason",
|
||||
"claim.amount",
|
||||
"claim.reason",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "goods_evidence_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.goods_name"],
|
||||
},
|
||||
{
|
||||
"id": "goods_outside_expense_scope",
|
||||
"operator": "not_in_scope",
|
||||
"left_fields": ["attachment.goods_name"],
|
||||
"right_fields": ["item.item_type", "item.item_reason"],
|
||||
},
|
||||
{
|
||||
"id": "missing_procurement_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["代采", "项目采购", "费用归集", "统一采购"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": [
|
||||
"goods_evidence_present",
|
||||
"goods_outside_expense_scope",
|
||||
"missing_procurement_exception",
|
||||
]
|
||||
},
|
||||
"summary": "发票品名与费用类型或明细事由不一致,且缺少采购归集说明时命中。",
|
||||
"message": "采购票据品名与费用类型不一致,需补充采购用途说明。",
|
||||
"semantic_type": "procurement_goods_category_consistency",
|
||||
},
|
||||
{
|
||||
"template_id": "corporate_card_date_consistency",
|
||||
"group": "corporate_card",
|
||||
"title": "企业卡交易日期一致性校验",
|
||||
"description": "用于企业卡消费日期与费用明细日期不一致的仿真规则。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "travel",
|
||||
"requires_attachment": True,
|
||||
"natural_language": (
|
||||
"企业卡费用报销时,读取企业卡交易日期、费用明细发生日期、开票日期、申报金额和报销事由。"
|
||||
"若企业卡交易日期或开票日期明显不在明细发生日期对应的业务期间内,"
|
||||
"且报销事由没有说明补录、跨月结算或集中开票原因,"
|
||||
"则标记为低风险,提示补充交易说明。"
|
||||
),
|
||||
"field_keys": [
|
||||
"attachment.issue_date",
|
||||
"item.item_date",
|
||||
"claim.amount",
|
||||
"claim.reason",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "card_date_evidence_present",
|
||||
"operator": "exists_any",
|
||||
"fields": ["attachment.issue_date", "item.item_date"],
|
||||
},
|
||||
{
|
||||
"id": "issue_date_outside_item_date",
|
||||
"operator": "date_outside_range",
|
||||
"date_fields": ["attachment.issue_date"],
|
||||
"range_start_fields": ["item.item_date"],
|
||||
"range_end_fields": ["item.item_date"],
|
||||
"tolerance_days": 7,
|
||||
},
|
||||
{
|
||||
"id": "missing_card_exception",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason"],
|
||||
"keywords": ["补录", "跨月结算", "集中开票", "统一结算"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {
|
||||
"all": ["card_date_evidence_present", "issue_date_outside_item_date", "missing_card_exception"]
|
||||
},
|
||||
"summary": "开票日期偏离明细发生日期超过容忍范围,且缺少企业卡结算说明时命中。",
|
||||
"message": "企业卡交易或开票日期与业务发生日期不一致,需补充说明。",
|
||||
"semantic_type": "corporate_card_date_consistency",
|
||||
},
|
||||
{
|
||||
"template_id": "general_missing_business_reason",
|
||||
"group": "general",
|
||||
"title": "报销事由完整性校验",
|
||||
"description": "识别事由过于笼统、缺少业务对象或用途说明的低风险提示。",
|
||||
"business_stage": "reimbursement",
|
||||
"expense_category": "all",
|
||||
"requires_attachment": False,
|
||||
"natural_language": (
|
||||
"费用报销时,读取报销事由、费用类型、申报金额、部门和明细事由。"
|
||||
"若报销事由没有包含项目、客户、会议、用途或审批等业务背景信息,"
|
||||
"且申报金额高于 300 元,则标记为低风险,提示补充业务背景说明。"
|
||||
),
|
||||
"field_keys": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"item.item_type",
|
||||
"claim.amount",
|
||||
"claim.department_name",
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"id": "amount_needs_reason_context",
|
||||
"operator": "numeric_compare",
|
||||
"left_fields": ["claim.amount"],
|
||||
"threshold": 300,
|
||||
"compare": "gt",
|
||||
},
|
||||
{
|
||||
"id": "missing_business_context",
|
||||
"operator": "not_contains_any",
|
||||
"fields": ["claim.reason", "item.item_reason"],
|
||||
"keywords": ["项目", "客户", "会议", "用途", "审批"],
|
||||
},
|
||||
],
|
||||
"hit_logic": {"all": ["amount_needs_reason_context", "missing_business_context"]},
|
||||
"summary": "申报金额超过低额阈值,且事由缺少业务背景关键词时命中。",
|
||||
"message": "报销事由缺少业务背景,请补充用途或审批说明。",
|
||||
"semantic_type": "general_business_reason_completeness",
|
||||
},
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from calendar import monthrange
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -506,8 +507,8 @@ class RiskRuleTemplateExecutor:
|
||||
for key in field_keys:
|
||||
for value in self._resolve_values(key, claim=claim, contexts=contexts):
|
||||
parsed = self._parse_date_value(value)
|
||||
if parsed and parsed not in values:
|
||||
values.append(parsed)
|
||||
if parsed and parsed not in values:
|
||||
values.append(parsed)
|
||||
return values
|
||||
|
||||
def _resolve_group_numbers(
|
||||
@@ -695,6 +696,9 @@ class RiskRuleTemplateExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None:
|
||||
application_date = RiskRuleTemplateExecutor._claim_application_trip_date(claim, start=start)
|
||||
if application_date is not None:
|
||||
return application_date
|
||||
item_dates = [
|
||||
item.item_date
|
||||
for item in list(claim.items or [])
|
||||
@@ -704,6 +708,166 @@ class RiskRuleTemplateExecutor:
|
||||
return min(item_dates) if start else max(item_dates)
|
||||
return getattr(claim, "occurred_at", None)
|
||||
|
||||
@staticmethod
|
||||
def _claim_application_trip_date(claim: ExpenseClaim, *, start: bool) -> date | None:
|
||||
windows: list[tuple[date, date]] = []
|
||||
reference_year = RiskRuleTemplateExecutor._claim_reference_year(claim)
|
||||
for raw_value in RiskRuleTemplateExecutor._iter_application_time_values(claim):
|
||||
windows.extend(
|
||||
RiskRuleTemplateExecutor._parse_date_windows(
|
||||
raw_value,
|
||||
reference_year=reference_year,
|
||||
)
|
||||
)
|
||||
if not windows:
|
||||
return None
|
||||
values = [window[0] if start else window[1] for window in windows]
|
||||
return min(values) if start else max(values)
|
||||
|
||||
@staticmethod
|
||||
def _claim_reference_year(claim: ExpenseClaim) -> int | None:
|
||||
for value in [getattr(claim, "occurred_at", None)]:
|
||||
parsed = RiskRuleTemplateExecutor._parse_date_value(value)
|
||||
if parsed is not None:
|
||||
return parsed.year
|
||||
for item in list(claim.items or []):
|
||||
parsed = RiskRuleTemplateExecutor._parse_date_value(getattr(item, "item_date", None))
|
||||
if parsed is not None:
|
||||
return parsed.year
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
|
||||
values: list[Any] = []
|
||||
application_sources = {"application_detail", "application_handoff", "application_link"}
|
||||
time_keys = (
|
||||
"application_time",
|
||||
"applicationTime",
|
||||
"application_date",
|
||||
"applicationDate",
|
||||
"business_time",
|
||||
"businessTime",
|
||||
"time_range",
|
||||
"timeRange",
|
||||
"time",
|
||||
"date",
|
||||
)
|
||||
nested_keys = (
|
||||
"application_detail",
|
||||
"applicationDetail",
|
||||
"review_form_values",
|
||||
"reviewFormValues",
|
||||
"expense_scene_selection",
|
||||
"expenseSceneSelection",
|
||||
)
|
||||
for flag in list(getattr(claim, "risk_flags_json", None) or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
source = str(flag.get("source") or "").strip()
|
||||
has_application_anchor = (
|
||||
source in application_sources
|
||||
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
|
||||
or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail"))
|
||||
)
|
||||
if not has_application_anchor:
|
||||
continue
|
||||
sources: list[dict[str, Any]] = [flag]
|
||||
for key in nested_keys:
|
||||
nested = flag.get(key)
|
||||
if isinstance(nested, dict):
|
||||
sources.append(nested)
|
||||
for source_dict in sources:
|
||||
for key in time_keys:
|
||||
value = source_dict.get(key)
|
||||
if value not in (None, ""):
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _parse_date_windows(
|
||||
value: Any,
|
||||
*,
|
||||
reference_year: int | None = None,
|
||||
) -> list[tuple[date, date]]:
|
||||
if isinstance(value, datetime):
|
||||
item = value.date()
|
||||
return [(item, item)]
|
||||
if isinstance(value, date):
|
||||
return [(value, value)]
|
||||
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
exact_dates = RiskRuleTemplateExecutor._parse_exact_dates(
|
||||
text,
|
||||
reference_year=reference_year,
|
||||
)
|
||||
if exact_dates:
|
||||
return [(min(exact_dates), max(exact_dates))]
|
||||
|
||||
month_windows = RiskRuleTemplateExecutor._parse_month_windows(
|
||||
text,
|
||||
reference_year=reference_year,
|
||||
)
|
||||
if month_windows:
|
||||
return month_windows
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _parse_exact_dates(text: str, *, reference_year: int | None = None) -> list[date]:
|
||||
values: list[date] = []
|
||||
|
||||
def append_date(year: int, month: int, day: int) -> None:
|
||||
try:
|
||||
parsed = date(year, month, day)
|
||||
except ValueError:
|
||||
return
|
||||
if parsed not in values:
|
||||
values.append(parsed)
|
||||
|
||||
for pattern in (
|
||||
r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})",
|
||||
r"(\d{4})年(\d{1,2})月(\d{1,2})日?",
|
||||
):
|
||||
for match in re.finditer(pattern, text):
|
||||
year, month, day = (int(part) for part in match.groups())
|
||||
append_date(year, month, day)
|
||||
|
||||
if reference_year is not None:
|
||||
for match in re.finditer(r"(?<!\d)(\d{1,2})月(\d{1,2})日?", text):
|
||||
month, day = (int(part) for part in match.groups())
|
||||
append_date(reference_year, month, day)
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _parse_month_windows(
|
||||
text: str,
|
||||
*,
|
||||
reference_year: int | None = None,
|
||||
) -> list[tuple[date, date]]:
|
||||
windows: list[tuple[date, date]] = []
|
||||
|
||||
def append_month(year: int, month: int) -> None:
|
||||
if month < 1 or month > 12:
|
||||
return
|
||||
last_day = monthrange(year, month)[1]
|
||||
window = (date(year, month, 1), date(year, month, last_day))
|
||||
if window not in windows:
|
||||
windows.append(window)
|
||||
|
||||
for match in re.finditer(r"(\d{4})[-/.](\d{1,2})(?![-/.]\d)", text):
|
||||
year, month = (int(part) for part in match.groups())
|
||||
append_month(year, month)
|
||||
for match in re.finditer(r"(\d{4})年(\d{1,2})月(?!\d)", text):
|
||||
year, month = (int(part) for part in match.groups())
|
||||
append_month(year, month)
|
||||
if reference_year is not None:
|
||||
for match in re.finditer(r"(?<!\d)(\d{1,2})月(?!\d|日)", text):
|
||||
append_month(reference_year, int(match.group(1)))
|
||||
return windows
|
||||
|
||||
@staticmethod
|
||||
def _condition_passes(operator: str, left_values: list[str], right_values: list[str]) -> bool:
|
||||
if operator == "is_empty":
|
||||
|
||||
@@ -15,12 +15,14 @@ from app.schemas.user_agent import (
|
||||
UserAgentSuggestedAction,
|
||||
)
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
)
|
||||
from app.services.user_agent_application_dates import expand_application_time_with_days
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
||||
|
||||
APPLICATION_CONTEXT_VALUES = {
|
||||
"application",
|
||||
@@ -152,7 +154,7 @@ class UserAgentApplicationMixin:
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。",
|
||||
"请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -170,7 +172,7 @@ class UserAgentApplicationMixin:
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是模拟的费用申请结果,请核对:",
|
||||
"这是费用申请核对结果,请核对:",
|
||||
self._build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
@@ -185,7 +187,11 @@ class UserAgentApplicationMixin:
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
"applicant": "",
|
||||
"grade": "",
|
||||
"department": "",
|
||||
"position": "",
|
||||
"manager_name": "",
|
||||
"lodging_daily_cap": "",
|
||||
"subsidy_daily_cap": "",
|
||||
"transport_policy": "",
|
||||
@@ -193,6 +199,12 @@ class UserAgentApplicationMixin:
|
||||
"matched_city": "",
|
||||
"rule_name": "",
|
||||
"rule_version": "",
|
||||
"hotel_amount": "",
|
||||
"allowance_amount": "",
|
||||
"transport_estimated_amount": "",
|
||||
"transport_estimate_source": "",
|
||||
"transport_estimate_confidence": "",
|
||||
"policy_total_amount": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
@@ -212,6 +224,41 @@ class UserAgentApplicationMixin:
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
context_json = payload.context_json or {}
|
||||
current_user = getattr(payload, "current_user", None)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or getattr(current_user, "name", "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
facts["department"] = str(
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or getattr(current_user, "department_name", "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
facts["position"] = str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
facts["manager_name"] = str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or getattr(current_user, "manager_name", "")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
@@ -219,6 +266,7 @@ class UserAgentApplicationMixin:
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
@staticmethod
|
||||
@@ -245,7 +293,11 @@ class UserAgentApplicationMixin:
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
"applicant": pick("applicant", "name", "userName", "user_name"),
|
||||
"grade": pick("grade"),
|
||||
"department": pick("department", "departmentName", "department_name"),
|
||||
"position": pick("position", "employeePosition", "employee_position"),
|
||||
"manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"),
|
||||
"lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"),
|
||||
"subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"),
|
||||
"transport_policy": pick("transportPolicy", "transport_policy"),
|
||||
@@ -253,6 +305,12 @@ class UserAgentApplicationMixin:
|
||||
"matched_city": pick("matchedCity", "matched_city"),
|
||||
"rule_name": pick("ruleName", "rule_name"),
|
||||
"rule_version": pick("ruleVersion", "rule_version"),
|
||||
"hotel_amount": pick("hotelAmount", "hotel_amount"),
|
||||
"allowance_amount": pick("allowanceAmount", "allowance_amount"),
|
||||
"transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"),
|
||||
"transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"),
|
||||
"transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"),
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
@@ -294,7 +352,7 @@ class UserAgentApplicationMixin:
|
||||
def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
|
||||
return [
|
||||
field
|
||||
for field in ("transport_mode", "amount")
|
||||
for field in ("transport_mode",)
|
||||
if not str(facts.get(field) or "").strip()
|
||||
]
|
||||
|
||||
@@ -558,7 +616,7 @@ class UserAgentApplicationMixin:
|
||||
def _display_application_slot_label(slot: str) -> str:
|
||||
return {
|
||||
"expense_type": "申请类型",
|
||||
"amount": "用户预估费用",
|
||||
"amount": "系统预估费用",
|
||||
"time_range": "发生时间",
|
||||
"time": "发生时间",
|
||||
"location": "地点",
|
||||
@@ -603,7 +661,7 @@ class UserAgentApplicationMixin:
|
||||
"reason": ("补充申请事由", "事由:"),
|
||||
"days": ("补充天数", "天数:"),
|
||||
"transport_mode": ("补充出行方式", "出行方式:"),
|
||||
"amount": ("补充预估费用", "用户预估费用:"),
|
||||
"amount": ("补充系统预估费用", "系统预估费用:"),
|
||||
}
|
||||
return config.get(field, ("补充申请信息", ""))
|
||||
|
||||
@@ -646,17 +704,21 @@ class UserAgentApplicationMixin:
|
||||
f"{label}:{value or '待补充'}"
|
||||
for label, value in (
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
("部门", facts.get("department", "")),
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("用户预估费用", facts.get("amount", "")),
|
||||
("系统预估费用", facts.get("amount", "")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -668,17 +730,21 @@ class UserAgentApplicationMixin:
|
||||
) -> str:
|
||||
rows = [
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
("部门", facts.get("department", "")),
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("用户预估费用", facts.get("amount", "")),
|
||||
("系统预估费用", facts.get("amount", "")),
|
||||
]
|
||||
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||
if not visible_rows:
|
||||
@@ -736,34 +802,53 @@ class UserAgentApplicationMixin:
|
||||
risk_flags_json=[self._build_application_detail_flag(facts)],
|
||||
)
|
||||
self.db.add(claim)
|
||||
self.db.flush()
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
platform_flags = list(platform_review.get("flags") or [])
|
||||
if platform_flags:
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
|
||||
return {
|
||||
"source": "application_detail",
|
||||
"severity": "info",
|
||||
"label": "申请详情",
|
||||
"application_detail": {
|
||||
"application_type": str(facts.get("application_type") or "").strip(),
|
||||
"time": str(facts.get("time") or "").strip(),
|
||||
"location": str(facts.get("location") or "").strip(),
|
||||
"reason": str(facts.get("reason") or "").strip(),
|
||||
"days": str(facts.get("days") or "").strip(),
|
||||
"transport_mode": str(facts.get("transport_mode") or "").strip(),
|
||||
"amount": str(facts.get("amount") or "").strip(),
|
||||
"grade": str(facts.get("grade") or "").strip(),
|
||||
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
|
||||
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
|
||||
"transport_policy": str(facts.get("transport_policy") or "").strip(),
|
||||
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
|
||||
"matched_city": str(facts.get("matched_city") or "").strip(),
|
||||
"rule_name": str(facts.get("rule_name") or "").strip(),
|
||||
"rule_version": str(facts.get("rule_version") or "").strip(),
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "application_detail",
|
||||
"severity": "info",
|
||||
"label": "申请详情",
|
||||
"application_detail": {
|
||||
"application_type": str(facts.get("application_type") or "").strip(),
|
||||
"time": str(facts.get("time") or "").strip(),
|
||||
"location": str(facts.get("location") or "").strip(),
|
||||
"reason": str(facts.get("reason") or "").strip(),
|
||||
"days": str(facts.get("days") or "").strip(),
|
||||
"transport_mode": str(facts.get("transport_mode") or "").strip(),
|
||||
"amount": str(facts.get("amount") or "").strip(),
|
||||
"grade": str(facts.get("grade") or "").strip(),
|
||||
"lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(),
|
||||
"subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(),
|
||||
"transport_policy": str(facts.get("transport_policy") or "").strip(),
|
||||
"policy_estimate": str(facts.get("policy_estimate") or "").strip(),
|
||||
"matched_city": str(facts.get("matched_city") or "").strip(),
|
||||
"rule_name": str(facts.get("rule_name") or "").strip(),
|
||||
"rule_version": str(facts.get("rule_version") or "").strip(),
|
||||
"hotel_amount": str(facts.get("hotel_amount") or "").strip(),
|
||||
"allowance_amount": str(facts.get("allowance_amount") or "").strip(),
|
||||
"transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(),
|
||||
"transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(),
|
||||
"transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(),
|
||||
"policy_total_amount": str(facts.get("policy_total_amount") or "").strip(),
|
||||
},
|
||||
},
|
||||
}
|
||||
"expense_application",
|
||||
)
|
||||
|
||||
def _resolve_application_manager_name(
|
||||
self,
|
||||
@@ -810,6 +895,13 @@ class UserAgentApplicationMixin:
|
||||
or context_json.get("departmentName")
|
||||
or ""
|
||||
).strip(),
|
||||
manager_name=str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or ""
|
||||
).strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -558,9 +558,16 @@ class UserAgentResponseMixin:
|
||||
payload.ontology,
|
||||
query_text=payload.message,
|
||||
)
|
||||
review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
|
||||
claim_service = ExpenseClaimService(self.db)
|
||||
business_stage = (
|
||||
"expense_application"
|
||||
if claim_service._is_expense_application_claim(claim)
|
||||
else "reimbursement"
|
||||
)
|
||||
review = claim_service.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
rule_codes=rule_codes,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
messages: list[str] = []
|
||||
for flag in review.get("flags") or []:
|
||||
|
||||
Reference in New Issue
Block a user