Files
X-Financial/server/tests/test_risk_rule_composite_generation.py
caoxiaozhu 0e861d8fa6 feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
2026-05-26 09:15:14 +08:00

362 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentAssetDomain
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
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.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class NullRuntimeChatService:
def complete(self, *args, **kwargs) -> None:
return None
class CompositeRuntimeChatService:
def complete(self, *args, **kwargs) -> str:
return json.dumps(
{
"name": "招待发票说明校验",
"description": "招待报销已取得发票但缺少客户说明时进入复核。",
"template_key": "composite_rule_v1",
"semantic_type": "entertainment_invoice_reason_check",
"field_keys": ["attachment.invoice_no", "claim.reason"],
"condition_summary": "D=发票号码E=报销事由D存在且E未说明客户名称时命中。",
"rule_ir": {
"facts": [
{"id": "D", "label": "发票号码", "fields": ["attachment.invoice_no"]},
{"id": "E", "label": "报销事由", "fields": ["claim.reason"]},
],
"hit_logic": "D AND NOT CONTAINS(E, 客户)",
},
"conditions": [
{
"id": "invoice_present",
"operator": "exists_any",
"fields": ["attachment.invoice_no"],
},
{
"id": "missing_customer_reason",
"operator": "not_contains_any",
"fields": ["claim.reason"],
"keywords": ["客户", "拜访对象"],
},
],
"hit_logic": {"all": ["invoice_present", "missing_customer_reason"]},
"formula": "HIT WHEN EXISTS(invoice_no) AND NOT CONTAINS(reason, 客户|拜访对象)",
"message_template": "招待发票已上传,但事由缺少客户或拜访对象说明。",
"keywords": [],
"exception_keywords": [],
"flow": {
"start": "招待报销提交",
"evidence": "读取发票号码和报销事由",
"decision": "是否有发票且事由缺少客户说明",
"pass": "客户说明完整,继续流转",
"fail": "缺少客户说明,进入复核",
},
},
ensure_ascii=False,
)
class LodgingSemanticRuntimeChatService:
def complete(self, *args, **kwargs) -> str:
return json.dumps(
{
"name": "住宿城市日期一致性校验",
"description": "住宿票据的城市和日期需要能对应本次差旅行程,缺少合理说明时进入复核。",
"template_key": "composite_rule_v1",
"semantic_type": "travel_lodging_city_date_consistency",
"field_keys": [
"attachment.hotel_city",
"attachment.stay_start_date",
"attachment.stay_end_date",
"attachment.issue_date",
"claim.location",
"item.item_location",
"attachment.route_cities",
"claim.trip_start_date",
"claim.trip_end_date",
"item.item_date",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"D=住宿票据事实A=住宿城市B=本次行程城市范围T=住宿日期或开票日期,"
"R=出差起止日期D存在且[(A不属于B)或(T超出R)]且无合理说明时命中。"
),
"rule_ir": {
"facts": [
{"id": "D", "label": "住宿票据事实", "fields": ["attachment.hotel_city", "attachment.ocr_text"]},
{"id": "A", "label": "住宿城市", "fields": ["attachment.hotel_city"]},
{"id": "B", "label": "本次行程城市范围", "fields": ["claim.location", "item.item_location", "attachment.route_cities"]},
{"id": "T", "label": "住宿日期或开票日期", "fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"]},
{"id": "R", "label": "出差起止日期", "fields": ["claim.trip_start_date", "claim.trip_end_date", "item.item_date"]},
{"id": "E", "label": "合理说明", "fields": ["claim.reason", "item.item_reason"]},
],
"hit_logic": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT CONTAINS(E, exception_keywords)",
},
"conditions": [
{
"id": "lodging_document_present",
"operator": "exists_any",
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
},
{
"id": "lodging_city_outside_trip_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
},
{
"id": "lodging_date_outside_trip_range",
"operator": "date_outside_range",
"date_fields": ["attachment.stay_start_date", "attachment.stay_end_date", "attachment.issue_date"],
"range_start_fields": ["claim.trip_start_date", "item.item_date"],
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
},
{
"id": "missing_reasonable_exception",
"operator": "not_contains_any",
"fields": ["claim.reason", "item.item_reason"],
"keywords": ["延期", "改签", "临时任务"],
},
],
"hit_logic": {
"all": [
"lodging_document_present",
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
"missing_reasonable_exception",
]
},
"formula": "D AND ((A NOT_IN B) OR DATE_OUTSIDE(T,R)) AND NOT EXCEPTION(E)",
"message_template": "住宿票据城市或日期无法与本次差旅行程形成一致关系,且未识别到合理说明。",
"keywords": [],
"exception_keywords": ["延期", "改签", "临时任务"],
"flow": {
"start": "差旅住宿报销提交",
"evidence": "读取住宿票据事实、行程范围和合理说明",
"decision": "住宿城市或日期是否脱离本次行程,且是否缺少合理说明",
"pass": "城市和日期均能对应行程,或已有合理说明",
"fail": "城市或日期无法对应行程,进入复核",
},
},
ensure_ascii=False,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def _read_payload(manager: AgentAssetRuleLibraryManager, asset: AgentAsset) -> dict:
return manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
def test_lodging_city_date_rule_generates_explainable_composite_json(tmp_path) -> None:
text = (
"差旅住宿报销时,先确认已上传住宿发票或酒店水单;再读取报销事由、申报目的地、"
"住宿城市、开票日期和行程城市。若住宿票据显示的城市不在本次差旅行程范围内,"
"或住宿发生时间明显早于出差开始、晚于出差结束,且没有延期、改签、临时任务等说明,"
"则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。"
)
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
service = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=LodgingSemanticRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="hotel",
rule_title="住宿城市日期一致性校验",
risk_level="high",
natural_language=text,
requires_attachment=True,
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
payload = _read_payload(manager, asset)
assert payload["template_key"] == "composite_rule_v1"
assert payload["semantic_type"] == "travel_lodging_city_date_consistency"
assert payload["params"]["semantic_type"] == "travel_lodging_city_date_consistency"
assert payload["params"]["keywords"] == []
assert "风险关键词" not in payload["params"]["condition_summary"]
assert "attachment.stay_start_date" in payload["params"]["field_keys"]
assert "claim.trip_start_date" in payload["params"]["field_keys"]
assert payload["params"]["rule_ir"]["facts"]
assert payload["params"]["hit_logic"]["all"][1]["any"] == [
"lodging_city_outside_trip_scope",
"lodging_date_outside_trip_range",
]
def test_composite_lodging_executor_hits_mismatch_and_respects_exception() -> None:
manifest = {
"template_key": "composite_rule_v1",
"params": {
"template_key": "composite_rule_v1",
"semantic_type": "travel_lodging_city_date_consistency",
"condition_summary": "住宿城市或日期不在本次差旅行程范围内且无合理说明时命中。",
"conditions": [
{
"id": "lodging_document_present",
"operator": "exists_any",
"fields": ["attachment.hotel_city", "attachment.ocr_text"],
},
{
"id": "lodging_city_outside_trip_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "attachment.route_cities"],
},
{
"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", "item.item_date"],
"range_end_fields": ["claim.trip_end_date", "item.item_date"],
},
{
"id": "missing_reasonable_exception",
"operator": "not_contains_any",
"fields": ["claim.reason", "item.item_reason"],
"keywords": ["延期", "改签", "临时任务"],
},
],
"hit_logic": {
"all": [
"lodging_document_present",
{"any": ["lodging_city_outside_trip_scope", "lodging_date_outside_trip_range"]},
"missing_reasonable_exception",
]
},
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = _build_claim(reason="去上海出差住宿", location="上海")
contexts = [
{
"document_info": {
"hotel_city": "北京",
"stay_start_date": "2026-05-11",
"stay_end_date": "2026-05-12",
"fields": [
{"key": "hotel_city", "label": "住宿城市", "value": "北京"},
{"key": "stay_start_date", "label": "入住日期", "value": "2026-05-11"},
{"key": "stay_end_date", "label": "离店日期", "value": "2026-05-12"},
],
},
"ocr_text": "北京酒店住宿发票",
}
]
result = RiskRuleTemplateExecutor().evaluate(manifest, claim=claim, contexts=contexts)
assert result is not None
assert result["evidence"]["condition_results"]["lodging_city_outside_trip_scope"] is True
claim_with_exception = _build_claim(reason="去上海出差住宿,因临时任务改签至北京", location="上海")
assert RiskRuleTemplateExecutor().evaluate(
manifest, claim=claim_with_exception, contexts=contexts
) is None
def test_model_generated_composite_rule_is_preserved_for_other_categories(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
service = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=CompositeRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="meal",
rule_title="招待发票客户说明校验",
risk_level="medium",
natural_language="招待报销时,如果已经上传发票但报销事由没有客户或拜访对象说明,则提示中风险。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
payload = _read_payload(manager, asset)
assert payload["template_key"] == "composite_rule_v1"
assert payload["semantic_type"] == "entertainment_invoice_reason_check"
assert payload["params"]["conditions"][0]["operator"] == "exists_any"
assert payload["params"]["hit_logic"] == {"all": ["invoice_present", "missing_customer_reason"]}
assert payload["params"]["message_template"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
claim = _build_claim(reason="招待费", location="上海")
result = RiskRuleTemplateExecutor().evaluate(
payload,
claim=claim,
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
)
assert result is not None
assert result["message"] == "招待发票已上传,但事由缺少客户或拜访对象说明。"
claim.reason = "招待客户 ACME 的餐费"
assert RiskRuleTemplateExecutor().evaluate(
payload,
claim=claim,
contexts=[{"document_info": {"invoice_no": "INV-20260526001"}}],
) is None
def _build_claim(*, reason: str, location: str) -> ExpenseClaim:
claim = ExpenseClaim(
claim_no="TEST-COMPOSITE-RISK",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason=reason,
location=location,
amount=Decimal("680.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, tzinfo=UTC),
status="draft",
)
claim.trip_start_date = date(2026, 5, 10)
claim.trip_end_date = date(2026, 5, 12)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 5, 11),
item_type="住宿费",
item_reason=reason,
item_location=location,
item_amount=Decimal("680.00"),
)
]
return claim