feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -33,6 +34,7 @@ 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.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
@@ -62,13 +64,12 @@ class TravelRouteSemanticRuntimeChatService:
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地;A与B无交集且无合理说明或A出现BC之外城市时命中。"
"A与B无交集且无合理说明或A出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
@@ -577,6 +578,39 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
assert "#10a37f" not in high_svg
def test_non_budget_platform_risk_manifests_do_not_use_budget_or_employee_location() -> None:
rule_root = Path("server/rules/risk-rules")
checked = 0
for path in sorted(rule_root.glob("*.json")):
payload = json.loads(path.read_text(encoding="utf-8"))
if is_budget_risk_manifest(payload):
continue
checked += 1
normalized = normalize_risk_rule_manifest(payload)
params = normalized.get("params") if isinstance(normalized.get("params"), dict) else {}
text_blob = json.dumps(normalized, ensure_ascii=False)
home_city_fields = params.get("home_city_fields")
condition_summary = str(
normalized.get("condition_summary") or params.get("condition_summary") or ""
)
template_key = str(
normalized.get("template_key") or params.get("template_key") or ""
).strip()
looks_like_city_rule = any(token in text_blob for token in ("城市", "目的地", "行程城市"))
assert "budget." not in text_blob, path.name
assert "employee.location" not in text_blob, path.name
assert not (
isinstance(home_city_fields, list)
and any(str(item or "").strip() for item in home_city_fields)
), path.name
assert "风险关键词" not in condition_summary, path.name
assert not (template_key == "keyword_match_v1" and looks_like_city_rule), path.name
assert checked == 28
def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
with build_session() as db:
service = AgentAssetService(db)
@@ -742,6 +776,280 @@ def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_dest
assert result is None
def test_travel_route_city_consistency_allows_inferred_round_trip_origin() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-INFERRED-ROUND-TRIP",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="上海",
amount=Decimal("708.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-INFERRED-ROUND-TRIP-EMP",
name="测试员工",
email="inferred-round-trip@example.com",
location="上海",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
},
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "上海-武汉"}],
},
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
"ocr_summary": "上海到武汉高铁票",
"item": claim.items[0],
},
],
)
assert result is None
def test_travel_route_city_consistency_uses_application_location_not_employee_origin() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-APPLICATION-LOCATION-NO-FALSE-POSITIVE",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="待补充",
amount=Decimal("354.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
risk_flags_json=[
{
"source": "application_link",
"application_claim_no": "AP-202606-LOCAL",
"application_detail": {
"application_location": "上海",
"application_reason": "支撑国网仿生产环境部署",
"application_time": "2026-02-20 至 2026-02-23",
},
}
],
)
claim.employee = Employee(
employee_no="TEST-APPLICATION-LOCATION-EMP",
name="测试员工",
email="application-location@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
}
],
)
assert result is None
def test_travel_route_city_mismatch_evidence_uses_application_claim_and_attachment() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-APPLICATION-LOCATION-MISMATCH",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="去北京参加项目会议",
location="北京",
amount=Decimal("354.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
risk_flags_json=[
{
"source": "application_link",
"application_claim_no": "AP-202606-MISMATCH",
"application_detail": {
"application_location": "北京",
"application_reason": "去北京参加项目会议",
"application_time": "2026-02-20 至 2026-02-23",
},
}
],
)
claim.employee = Employee(
employee_no="TEST-APPLICATION-MISMATCH-EMP",
name="测试员工",
email="application-mismatch@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="去北京参加项目会议",
item_location="北京",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
}
],
)
assert result is not None
evidence = result["evidence"]["city_consistency"]
assert evidence["application_reference_values"] == ["北京"]
assert evidence["claim_reference_values"] == ["北京"]
assert evidence["attachment_values"] == ["武汉", "上海"]
assert evidence["unexpected_route_cities"] == ["武汉", "上海"]
assert "home_values" not in evidence
assert "ignored_employee_context_values" not in evidence
def test_travel_route_city_consistency_still_hits_onward_city_after_destination() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-ONWARD-CITY",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="上海",
amount=Decimal("840.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-ONWARD-CITY-EMP",
name="测试员工",
email="onward-city@example.com",
location="上海",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("480.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "flight_itinerary",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "电子行程单 2026-02-20 武汉-上海 金额 480元",
"ocr_summary": "武汉到上海机票",
"item": claim.items[0],
},
{
"document_info": {
"document_type": "flight_itinerary",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "上海-成都"}],
},
"ocr_text": "电子行程单 2026-02-21 上海-成都 金额 360元",
"ocr_summary": "上海到成都机票",
"item": claim.items[0],
},
],
)
assert result is not None
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["成都"]
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
@@ -783,7 +1091,7 @@ def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_p
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 "employee.location" not in payload["params"]["field_keys"]
assert "route_anomaly_policy" in payload["params"]
@@ -882,10 +1190,10 @@ def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_exe
)
assert result is not None
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"]
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京", "武汉"]
def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None:
def test_travel_route_rule_does_not_use_employee_location_as_allowed_endpoint() -> None:
manifest = {
"template_key": "field_compare_v1",
"params": {
@@ -904,8 +1212,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
"exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
"condition_summary": (
"A=票据路线城市B=申报城市,C=员工常驻地,"
"A中出现BC之外城市则命中。"
"A=票据路线城市B=申报城市,"
"A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。"
),
},
"outcomes": {"fail": {"severity": "high"}},
@@ -962,8 +1270,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
assert result is not None
evidence = result["evidence"]["city_consistency"]
assert evidence["reference_values"] == ["上海"]
assert evidence["home_values"] == ["武汉"]
assert evidence["unexpected_route_cities"] == ["北京"]
assert evidence["unexpected_route_cities"] == ["北京", "武汉"]
assert "home_values" not in evidence
def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None: