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