feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
@@ -1,36 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.expense_claim_constants import (
|
||||
AI_REVIEW_LOOKBACK_DAYS,
|
||||
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
|
||||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
||||
DOCUMENT_FACT_ITEM_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
build_default_expense_rule_catalog,
|
||||
)
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
@@ -66,9 +49,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
if severity == "high" or action == "block":
|
||||
blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip())
|
||||
|
||||
deduplicated_reasons = list(
|
||||
dict.fromkeys(reason for reason in blocking_reasons if reason)
|
||||
)
|
||||
deduplicated_reasons = list(dict.fromkeys(reason for reason in blocking_reasons if reason))
|
||||
return {"flags": flags, "blocking_reasons": deduplicated_reasons}
|
||||
|
||||
def _load_platform_risk_rule_manifests(
|
||||
@@ -77,9 +58,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
rule_codes: list[str] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
code_filter = {
|
||||
str(code or "").strip()
|
||||
for code in list(rule_codes or [])
|
||||
if str(code or "").strip()
|
||||
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
|
||||
}
|
||||
manifests_by_code: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -224,12 +203,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
normalized_contexts.append(
|
||||
{
|
||||
"scene_code": str(document_info.get("scene_code") or "").strip().lower(),
|
||||
"document_type": str(
|
||||
document_info.get("document_type") or ""
|
||||
).strip().lower(),
|
||||
"item_type": str(
|
||||
getattr(context.get("item"), "item_type", "") or ""
|
||||
).strip().lower(),
|
||||
"document_type": str(document_info.get("document_type") or "").strip().lower(),
|
||||
"item_type": str(getattr(context.get("item"), "item_type", "") or "")
|
||||
.strip()
|
||||
.lower(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -312,6 +289,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
if evaluator == "template_rule":
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=contexts,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=str(result.get("message") or "自然语言风险规则命中。"),
|
||||
evidence=result.get("evidence") if isinstance(result.get("evidence"), dict) else {},
|
||||
)
|
||||
return None
|
||||
|
||||
def _evaluate_reason_too_brief_risk(
|
||||
@@ -347,9 +337,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
reason_corpus = self._build_scene_reason_corpus(claim)
|
||||
compact_reason = re.sub(r"\s+", "", reason_corpus)
|
||||
looks_like_entertainment = (
|
||||
"entertainment" in expense_types
|
||||
or "招待" in compact_reason
|
||||
or "客户" in compact_reason
|
||||
"entertainment" in expense_types or "招待" in compact_reason or "客户" in compact_reason
|
||||
)
|
||||
if not looks_like_entertainment:
|
||||
return None
|
||||
@@ -374,32 +362,28 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
for context in contexts:
|
||||
item = context["item"]
|
||||
item_type = (
|
||||
str(item.item_type or claim.expense_type or "other").strip().lower()
|
||||
or "other"
|
||||
str(item.item_type or claim.expense_type or "other").strip().lower() or "other"
|
||||
)
|
||||
policy = self._get_expense_scene_policy(item_type)
|
||||
if policy is None:
|
||||
continue
|
||||
document_info = context.get("document_info") or {}
|
||||
recognized_scene_code = (
|
||||
str(document_info.get("scene_code") or "other").strip().lower()
|
||||
or "other"
|
||||
str(document_info.get("scene_code") or "other").strip().lower() or "other"
|
||||
)
|
||||
recognized_document_type = (
|
||||
str(document_info.get("document_type") or "other").strip().lower()
|
||||
or "other"
|
||||
str(document_info.get("document_type") or "other").strip().lower() or "other"
|
||||
)
|
||||
if (
|
||||
recognized_scene_code in set(policy.allowed_scene_codes)
|
||||
or recognized_document_type in set(policy.allowed_document_types)
|
||||
):
|
||||
if recognized_scene_code in set(
|
||||
policy.allowed_scene_codes
|
||||
) or recognized_document_type in set(policy.allowed_document_types):
|
||||
continue
|
||||
recognized_label = str(
|
||||
document_info.get("document_type_label")
|
||||
or recognized_document_type
|
||||
or "未知票据"
|
||||
document_info.get("document_type_label") or recognized_document_type or "未知票据"
|
||||
)
|
||||
mismatches.append(
|
||||
f"第 {context['index']} 条明细为{policy.label},附件识别为{recognized_label}"
|
||||
)
|
||||
mismatches.append(f"第 {context['index']} 条明细为{policy.label},附件识别为{recognized_label}")
|
||||
|
||||
if not mismatches:
|
||||
return None
|
||||
@@ -437,7 +421,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
evidence_text = "、".join(evidence_cities[:5])
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,建议补充异地说明或更换附件。",
|
||||
message=(
|
||||
f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,"
|
||||
"建议补充异地说明或更换附件。"
|
||||
),
|
||||
evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities},
|
||||
)
|
||||
|
||||
@@ -450,9 +437,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
) -> dict[str, Any] | None:
|
||||
invoice_keys = self._collect_invoice_keys_from_contexts(contexts)
|
||||
duplicate_keys = [
|
||||
key
|
||||
for key, count in self._count_values(invoice_keys).items()
|
||||
if count > 1
|
||||
key for key, count in self._count_values(invoice_keys).items() if count > 1
|
||||
]
|
||||
if duplicate_keys:
|
||||
return self._build_platform_risk_flag(
|
||||
@@ -504,9 +489,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
) -> dict[str, Any] | None:
|
||||
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||
allow_keywords = [
|
||||
str(value)
|
||||
for value in list(params.get("allow_keywords") or [])
|
||||
if str(value).strip()
|
||||
str(value) for value in list(params.get("allow_keywords") or []) if str(value).strip()
|
||||
]
|
||||
claimant = str(claim.employee_name or "").strip()
|
||||
if not claimant:
|
||||
@@ -564,7 +547,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
return None
|
||||
return self._build_platform_risk_flag(
|
||||
manifest,
|
||||
message=f"票据年份 {mismatch_years[0]} 与费用发生年份 {claim_year} 不一致,建议确认是否跨年报销。",
|
||||
message=(
|
||||
f"票据年份 {mismatch_years[0]} 与费用发生年份 {claim_year} 不一致,"
|
||||
"建议确认是否跨年报销。"
|
||||
),
|
||||
evidence={"claim_year": claim_year, "invoice_years": mismatch_years},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user