feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -11,7 +11,8 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
|
||||
from app.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
@@ -23,11 +24,15 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
|
||||
from app.services.risk_rule_flow_diagram import (
|
||||
RiskRuleFlowDiagramRenderer,
|
||||
RiskRuleFlowDiagramSpec,
|
||||
)
|
||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
@@ -35,6 +40,41 @@ class NullRuntimeChatService:
|
||||
return None
|
||||
|
||||
|
||||
class TravelRouteSemanticRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"name": "差旅票据路线一致性校验",
|
||||
"description": "交通票或住宿票据城市需要与申报行程形成一致关系。",
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
],
|
||||
"condition_summary": (
|
||||
"A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地;A与B无交集且无合理说明,或A出现B∪C之外城市时命中。"
|
||||
),
|
||||
"keywords": [],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"flow": {
|
||||
"start": "差旅报销提交",
|
||||
"evidence": "读取票据城市、申报地点、明细地点和报销事由",
|
||||
"decision": "票据城市是否覆盖申报行程,是否出现额外中转城市",
|
||||
"pass": "票据城市与申报行程一致",
|
||||
"fail": "票据城市与申报行程不一致,进入复核",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
@@ -58,6 +98,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅住宿城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
),
|
||||
@@ -66,6 +107,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.name == "差旅住宿城市一致性校验"
|
||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||
assert asset.config_json["detail_mode"] == "json_risk"
|
||||
assert asset.config_json["evaluator"] == "template_rule"
|
||||
@@ -78,17 +120,23 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
|
||||
payload = json.loads(rule_path.read_text(encoding="utf-8"))
|
||||
assert payload["rule_code"] == asset.code
|
||||
assert payload["name"] == "差旅住宿城市一致性校验"
|
||||
assert payload["applies_to"]["expense_categories"] == ["travel"]
|
||||
assert payload["risk_category"] == "差旅费"
|
||||
assert payload["metadata"]["expense_category"] == "travel"
|
||||
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
|
||||
assert payload["outcomes"]["fail"]["severity"] == "high"
|
||||
assert payload["template_key"] == "field_compare_v1"
|
||||
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
||||
assert payload["inputs"]["fields"]
|
||||
assert payload["flow_diagram_svg"].startswith("<svg")
|
||||
assert 'width="760" height="280"' in payload["flow_diagram_svg"]
|
||||
assert 'width="860" height="360"' in payload["flow_diagram_svg"]
|
||||
assert 'data-risk-flow-style="review-node-only"' in payload["flow_diagram_svg"]
|
||||
assert 'data-risk-flow-detail="logic-v2"' in payload["flow_diagram_svg"]
|
||||
assert "RULE FLOW" in payload["flow_diagram_svg"]
|
||||
assert "字段事实" in payload["flow_diagram_svg"]
|
||||
assert "判断条件" in payload["flow_diagram_svg"]
|
||||
assert "命中逻辑" in payload["flow_diagram_svg"]
|
||||
assert "进入复核" in payload["flow_diagram_svg"]
|
||||
assert "否" in payload["flow_diagram_svg"]
|
||||
assert "是" in payload["flow_diagram_svg"]
|
||||
@@ -99,6 +147,113 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
||||
assert "feDropShadow" not in payload["flow_diagram_svg"]
|
||||
|
||||
|
||||
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
|
||||
asset_id = generator.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅住宿城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset_service = AgentAssetService(db)
|
||||
asset_service.rule_library_manager = manager
|
||||
updated = asset_service.set_risk_rule_level(
|
||||
asset_id,
|
||||
risk_level="low",
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
assert updated.config_json["severity"] == "low"
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.config_json["risk_level_label"] == "低风险"
|
||||
file_name = asset.config_json["rule_document"]["file_name"]
|
||||
payload = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
)
|
||||
assert payload["outcomes"]["fail"]["severity"] == "low"
|
||||
assert payload["metadata"]["risk_level"] == "low"
|
||||
assert payload["metadata"]["risk_level_label"] == "低风险"
|
||||
assert "低风险" in payload["metadata"]["flow"]["fail"]
|
||||
assert "#2563eb" in payload["flow_diagram_svg"]
|
||||
assert "#dc2626" not in payload["flow_diagram_svg"]
|
||||
|
||||
version = asset_service.repository.get_version(asset_id, asset.working_version)
|
||||
assert version is not None
|
||||
assert '"severity": "low"' in version.content
|
||||
|
||||
|
||||
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
service = RiskRuleGenerationJobService(
|
||||
db,
|
||||
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
request = AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="差旅城市一致性校验",
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
)
|
||||
|
||||
asset_id = service.enqueue_rule_asset_generation(request, actor="pytest")
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.status == AgentAssetStatus.GENERATING.value
|
||||
assert asset.owner == "pytest"
|
||||
assert asset.name == "差旅城市一致性校验"
|
||||
assert asset.config_json["generation_status"] == "generating"
|
||||
assert asset.config_json["expense_category_label"] == "差旅费"
|
||||
assert asset.current_version is None
|
||||
detail = AgentAssetService(db).get_asset(asset_id)
|
||||
assert detail is not None
|
||||
assert detail.status == AgentAssetStatus.GENERATING.value
|
||||
assert detail.latest_test_summary is None
|
||||
|
||||
service.complete_rule_asset_generation(asset_id, request, actor="pytest")
|
||||
db.refresh(asset)
|
||||
|
||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||
assert asset.working_version == "v0.1.0"
|
||||
assert asset.config_json["generation_status"] == "completed"
|
||||
assert asset.config_json["expense_category_label"] == "差旅费"
|
||||
assert asset.scenario_json == ["差旅费"]
|
||||
|
||||
|
||||
def test_platform_risk_sync_skips_natural_language_drafts() -> None:
|
||||
assert AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
|
||||
{
|
||||
"rule_code": "risk.expense.travel.generated_20260525123000000000",
|
||||
"metadata": {
|
||||
"stability": "generated_draft",
|
||||
"source_ref": "自然语言风险规则",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert not AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
|
||||
{
|
||||
"rule_code": "risk.travel.destination_location_mismatch",
|
||||
"metadata": {"source_ref": "平台内置风险规则"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
renderer = RiskRuleFlowDiagramRenderer()
|
||||
|
||||
@@ -123,10 +278,380 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
|
||||
assert "#f97316" in render("medium", "中风险")
|
||||
high_svg = render("high", "高风险")
|
||||
assert "#dc2626" in high_svg
|
||||
assert high_svg.count("#dc2626") == 1
|
||||
assert high_svg.count("#dc2626") >= 1
|
||||
assert "#10a37f" not in high_svg
|
||||
|
||||
|
||||
def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
value = service._find_attachment_field_value(
|
||||
"attachment.route_cities",
|
||||
"行程城市",
|
||||
[
|
||||
{
|
||||
"document_fields": [
|
||||
{"key": "route", "label": "行程路线", "value": "上海虹桥-武汉"}
|
||||
],
|
||||
"ocr_text": "G123 上海虹桥 至 武汉 二等座",
|
||||
"summary": "高铁票 上海虹桥-武汉",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert value == ["上海", "武汉"]
|
||||
|
||||
|
||||
def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> None:
|
||||
manifest = {
|
||||
"template_key": "keyword_match_v1",
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
],
|
||||
"search_fields": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
],
|
||||
"natural_language": "差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否一致",
|
||||
"keywords": ["绕行", "跨城", "改签", "变更"],
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "medium"}},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-CURRENT-RISK",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去北京出差3天",
|
||||
location="北京",
|
||||
amount=Decimal("320.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去北京出差3天",
|
||||
item_location="北京",
|
||||
item_amount=Decimal("320.00"),
|
||||
)
|
||||
]
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[
|
||||
{
|
||||
"document_info": {
|
||||
"route_cities": ["武汉", "上海"],
|
||||
"fields": [
|
||||
{"key": "route_cities", "label": "行程城市", "value": ["武汉", "上海"]}
|
||||
],
|
||||
},
|
||||
"ocr_text": "武汉 到 上海",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["evidence"]["city_consistency"]["attachment_values"] == ["武汉", "上海"]
|
||||
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
|
||||
|
||||
|
||||
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(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=TravelRouteSemanticRuntimeChatService(),
|
||||
)
|
||||
|
||||
asset_id = service.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
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 = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
assert payload["template_key"] == "field_compare_v1"
|
||||
assert payload["semantic_type"] == "travel_route_city_consistency"
|
||||
assert payload["params"]["semantic_type"] == "travel_route_city_consistency"
|
||||
assert payload["params"]["keywords"] == []
|
||||
assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
|
||||
assert "A=交通票行程城市" in payload["params"]["condition_summary"]
|
||||
assert "风险关键词" not in payload["params"]["condition_summary"]
|
||||
assert "employee.location" in payload["params"]["field_keys"]
|
||||
assert "route_anomaly_policy" in payload["params"]
|
||||
|
||||
|
||||
def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_execution() -> None:
|
||||
manifest = {
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.expense.travel.legacy_city_keyword",
|
||||
"name": "差旅票据路线一致性校验",
|
||||
"description": "差旅报销时读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"risk_category": "差旅费",
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{"key": "attachment.hotel_city", "label": "住宿城市"},
|
||||
{"key": "claim.location", "label": "申报地点"},
|
||||
{"key": "attachment.route_cities", "label": "行程城市"},
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
],
|
||||
"search_fields": [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
],
|
||||
"keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
|
||||
"natural_language": (
|
||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
|
||||
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
|
||||
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险。"
|
||||
),
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
"metadata": {
|
||||
"condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
|
||||
"flow": {
|
||||
"decision": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
|
||||
},
|
||||
},
|
||||
"flow_diagram_svg": (
|
||||
'<svg data-risk-flow-style="review-node-only">'
|
||||
"检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
|
||||
"</svg>"
|
||||
),
|
||||
}
|
||||
|
||||
normalized = normalize_risk_rule_manifest(manifest)
|
||||
assert normalized["template_key"] == "field_compare_v1"
|
||||
assert normalized["semantic_type"] == "travel_route_city_consistency"
|
||||
assert normalized["params"]["keywords"] == []
|
||||
assert "风险关键词" not in normalized["params"]["condition_summary"]
|
||||
assert "风险关键词" not in normalized["metadata"]["flow"]["decision"]
|
||||
assert "风险关键词" not in normalized["flow_diagram_svg"]
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-LEGACY-NORMALIZER",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去上海办事",
|
||||
location="上海",
|
||||
amount=Decimal("520.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-EMPLOYEE",
|
||||
name="测试员工",
|
||||
email="legacy-route-risk@example.com",
|
||||
location="武汉",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去上海办事",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("520.00"),
|
||||
)
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
normalized,
|
||||
claim=claim,
|
||||
contexts=[{"document_info": {"route_cities": ["上海", "北京", "武汉"]}}],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"]
|
||||
|
||||
|
||||
def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None:
|
||||
manifest = {
|
||||
"template_key": "field_compare_v1",
|
||||
"params": {
|
||||
"template_key": "field_compare_v1",
|
||||
"semantic_type": "travel_route_city_consistency",
|
||||
"field_keys": [
|
||||
"attachment.route_cities",
|
||||
"claim.location",
|
||||
"item.item_location",
|
||||
"employee.location",
|
||||
"claim.reason",
|
||||
],
|
||||
"attachment_city_fields": ["attachment.route_cities"],
|
||||
"reference_city_fields": ["claim.location", "item.item_location"],
|
||||
"home_city_fields": ["employee.location"],
|
||||
"exception_fields": ["claim.reason"],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
"condition_summary": "A=票据路线城市,B=申报城市,C=员工常驻地,A中出现B∪C之外城市则命中。",
|
||||
},
|
||||
"outcomes": {"fail": {"severity": "high"}},
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
claim_no="TEST-ROUTE-ANOMALY",
|
||||
employee_name="测试员工",
|
||||
department_name="测试部门",
|
||||
expense_type="差旅费",
|
||||
reason="去上海办事",
|
||||
location="上海",
|
||||
amount=Decimal("520.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime.now(UTC),
|
||||
status="draft",
|
||||
)
|
||||
claim.employee = Employee(
|
||||
employee_no="TEST-EMPLOYEE",
|
||||
name="测试员工",
|
||||
email="route-risk@example.com",
|
||||
location="武汉",
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
item_date=date.today(),
|
||||
item_type="交通费",
|
||||
item_reason="去上海办事",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("520.00"),
|
||||
)
|
||||
]
|
||||
|
||||
result = RiskRuleTemplateExecutor().evaluate(
|
||||
manifest,
|
||||
claim=claim,
|
||||
contexts=[
|
||||
{
|
||||
"document_info": {
|
||||
"route_cities": ["上海", "北京", "武汉"],
|
||||
"fields": [
|
||||
{"key": "route_cities", "label": "行程城市", "value": ["上海", "北京", "武汉"]}
|
||||
],
|
||||
},
|
||||
"ocr_text": "上海 到 北京 到 武汉",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
evidence = result["evidence"]["city_consistency"]
|
||||
assert evidence["reference_values"] == ["上海"]
|
||||
assert evidence["home_values"] == ["武汉"]
|
||||
assert evidence["unexpected_route_cities"] == ["北京"]
|
||||
|
||||
|
||||
def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=manager,
|
||||
runtime_chat_service=NullRuntimeChatService(),
|
||||
)
|
||||
asset_id = generator.generate_rule_asset(
|
||||
AgentAssetRiskRuleGenerateRequest(
|
||||
business_domain=AgentAssetDomain.EXPENSE,
|
||||
expense_category="travel",
|
||||
rule_title="当前差旅票据城市一致性规则",
|
||||
risk_level="medium",
|
||||
natural_language="差旅报销时,交通票或住宿票据中的城市必须与申报目的地一致;未说明绕行、跨城或改签原因时标记风险。",
|
||||
requires_attachment=True,
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
service = AgentAssetService(db)
|
||||
service.rule_library_manager = manager
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
manifest = manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
)
|
||||
manifest["template_key"] = "keyword_match_v1"
|
||||
manifest["params"]["template_key"] = "keyword_match_v1"
|
||||
manifest["params"]["keywords"] = ["绕行", "跨城", "改签", "变更"]
|
||||
manifest["params"]["search_fields"] = [
|
||||
"attachment.hotel_city",
|
||||
"claim.location",
|
||||
"attachment.route_cities",
|
||||
"item.item_location",
|
||||
]
|
||||
manifest["params"]["field_keys"] = manifest["params"]["search_fields"]
|
||||
manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=asset.config_json["rule_document"]["file_name"],
|
||||
payload=manifest,
|
||||
)
|
||||
|
||||
simulation = service.simulate_risk_rule_message(
|
||||
asset_id,
|
||||
AgentAssetRiskRuleSimulationRequest(
|
||||
message="去北京出差3天",
|
||||
attachments=[
|
||||
{
|
||||
"name": "train-ticket.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"ocr_text": "武汉 到 上海",
|
||||
"summary": "高铁票 武汉-上海",
|
||||
"document_fields": [
|
||||
{"key": "route", "label": "行程路线", "value": "武汉-上海"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
assert simulation.ready is True
|
||||
assert simulation.hit is True
|
||||
assert simulation.field_values["claim.location"] == "北京"
|
||||
assert simulation.field_values["attachment.route_cities"] == ["武汉", "上海"]
|
||||
|
||||
|
||||
def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> None:
|
||||
with build_session() as db:
|
||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||
|
||||
Reference in New Issue
Block a user