后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
362 lines
16 KiB
Python
362 lines
16 KiB
Python
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
|