Files
X-Financial/server/tests/test_risk_rule_generation.py
caoxiaozhu 34457f9c3e feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
2026-06-03 15:46:56 +08:00

1553 lines
60 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 pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.agent_enums import (
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentReviewStatus,
)
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.agent_asset import (
AgentAssetReviewCreate,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleReportRequest,
AgentAssetRiskRuleSampleTestRequest,
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleSimulationRequest,
)
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.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,
)
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_scoring import calculate_risk_rule_score, risk_level_from_score
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class NullRuntimeChatService:
def complete(self, *args, **kwargs) -> None:
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",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"A与B无交集且无合理说明或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
),
"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 test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
with build_session() as db:
service = RiskRuleGenerationService(
db,
rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
rule_title="差旅住宿城市一致性校验",
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
),
actor="pytest",
)
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"
assert asset.config_json["expense_category"] == "travel"
assert asset.config_json["risk_category"] == "差旅费"
assert asset.config_json["business_stage"] == "reimbursement"
assert asset.config_json["business_stage_label"] == "费用报销"
assert asset.scenario_json == ["差旅费"]
assert asset.current_version == "v0.1.0"
file_name = asset.config_json["rule_document"]["file_name"]
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["applies_to"]["business_stages"] == ["reimbursement"]
assert payload["risk_category"] == "差旅费"
assert payload["metadata"]["expense_category"] == "travel"
assert payload["metadata"]["business_stage"] == "reimbursement"
assert payload["metadata"]["business_stage_label"] == "费用报销"
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
assert isinstance(payload["metadata"]["risk_score"], int)
assert payload["metadata"]["risk_level"] == payload["outcomes"]["fail"]["severity"]
assert asset.config_json["risk_score"] == payload["metadata"]["risk_score"]
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="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"]
assert "#dc2626" in payload["flow_diagram_svg"]
assert "#fecaca" in payload["flow_diagram_svg"]
assert "#10a37f" not in payload["flow_diagram_svg"]
assert "#f97316" not in payload["flow_diagram_svg"]
assert "feDropShadow" not in payload["flow_diagram_svg"]
def test_risk_score_model_thresholds_and_critical_level() -> None:
assert risk_level_from_score(30) == "low"
assert risk_level_from_score(31) == "medium"
assert risk_level_from_score(61) == "high"
assert risk_level_from_score(81) == "critical"
result = calculate_risk_rule_score(
natural_language="同一发票号码重复报销时禁止提交并进入审计复核。",
draft={
"template_key": "composite_rule_v1",
"field_keys": ["attachment.invoice_no", "claim.amount"],
"conditions": [{"id": "duplicate_invoice", "operator": "overlap"}],
"risk_scoring_evidence": {
"impact_level": "critical",
"violation_certainty": "critical",
"evidence_strength": "high",
"exception_dependence": "medium",
"control_action": "block",
"business_sensitivity": "critical",
},
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] >= 81
assert result["level"] == "critical"
assert result["level_label"] == "极高风险"
def test_generate_expense_application_risk_rule_marks_business_stage(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=NullRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
business_stage="expense_application",
expense_category="travel",
rule_title="差旅申请预算余额校验",
natural_language="费用申请时,若差旅申请金额超过可用预算余额,则提示风险并要求补充审批说明。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["business_stage"] == "expense_application"
assert asset.config_json["business_stage_label"] == "费用申请"
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
assert payload["applies_to"]["business_stages"] == ["expense_application"]
assert payload["metadata"]["business_stage_label"] == "费用申请"
assert payload["params"]["business_stage_label"] == "费用申请"
def test_generate_risk_rule_asset_supports_all_expense_category(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=NullRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
business_stage="expense_application",
expense_category="all",
rule_title="预算可用余额不足",
natural_language="费用申请时,如果申请金额超过预算可用余额,则提示预算风险并要求补充说明。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["expense_category"] == "all"
assert asset.config_json["expense_category_label"] == "全部"
assert asset.scenario_json == ["全部"]
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
assert payload["applies_to"]["expense_categories"] == ["all"]
assert payload["metadata"]["expense_category"] == "all"
assert payload["metadata"]["expense_category_label"] == "全部"
def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None:
field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"]
result = calculate_risk_rule_score(
natural_language=(
"差旅报销时,票据已上传但发票号码或商品服务名称缺失,"
"且报销事由、人员和部门能够说明费用归属,则标记为低风险,"
"仅提醒补齐票据要素。"
),
draft={
"template_key": "field_required_v1",
"field_keys": field_keys,
"condition_summary": "票据要素缺失但归属清晰时提醒补齐。",
},
fields=[SimpleNamespace(key=key, source=key.split(".", 1)[0]) for key in field_keys],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] <= 30
assert result["level"] == "low"
assert result["calibration"]["rules"][0]["name"] == "explicit_low_control_cap"
def test_risk_score_model_ignores_negated_hard_risk_words_for_low_rules() -> None:
result = calculate_risk_rule_score(
natural_language=(
"差旅费报销提交时,若缺少申报目的地、明细地点或明细事由,"
"但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,"
"提示经办人补齐基础差旅信息后继续提交。"
),
draft={
"template_key": "field_required_v1",
"field_keys": ["claim.location", "item.item_location", "item.item_reason"],
"condition_summary": "基础差旅字段缺失但暂无硬风险迹象时提示补齐。",
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=False,
)
assert result["score"] <= 30
assert result["level"] == "low"
def test_risk_score_model_does_not_cap_hard_risk_signals() -> None:
result = calculate_risk_rule_score(
natural_language=(
"差旅报销时,交通票或住宿票据城市均无法与申报目的地一致,"
"且没有绕行、跨城办事或改签说明,则标记为高风险,要求补充说明或退回修改。"
),
draft={
"template_key": "composite_rule_v1",
"field_keys": ["claim.destination_city", "attachment.route_cities"],
"conditions": [{"id": "city_mismatch", "operator": "not_overlap"}],
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] >= 61
assert result["level"] == "high"
assert not result["calibration"]["rules"]
def test_set_risk_rule_level_rejects_manual_override(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
with pytest.raises(ValueError, match="评分模型"):
asset_service.set_risk_rule_level(
asset_id,
risk_level="low",
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["severity"] != "low"
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_stale_demo_risk_rules_are_marked_deprecated() -> None:
class FoundationRiskSyncProbe(AgentFoundationRiskRuleMixin):
def __init__(self, db: Session) -> None:
self.db = db
with build_session() as db:
stale_asset = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="risk.standard.training_per_capita_over_limit",
name="培训费人均超标准",
domain=AgentAssetDomain.EXPENSE.value,
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
config_json={
"enabled": True,
"tag": "风险规则",
"source_ref": "费用管控 Demo 风险规则库",
},
)
kept_asset = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="risk.standard.software_contract_missing",
name="软件服务费缺少合同",
domain=AgentAssetDomain.EXPENSE.value,
owner="风控与审计部",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
config_json={
"enabled": True,
"tag": "风险规则",
"source_ref": "费用管控 Demo 风险规则库",
},
)
db.add_all([stale_asset, kept_asset])
db.flush()
FoundationRiskSyncProbe(db)._hide_stale_demo_risk_rules(
{"risk.standard.software_contract_missing"}
)
assert stale_asset.status == AgentAssetStatus.DISABLED.value
assert stale_asset.config_json["tag"] == "废弃风险规则"
assert stale_asset.config_json["enabled"] is False
assert kept_asset.status == AgentAssetStatus.ACTIVE.value
assert kept_asset.config_json["tag"] == "风险规则"
def test_platform_risk_applies_to_chinese_expense_type_labels() -> None:
class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
pass
claim = ExpenseClaim(
claim_no="TEST-MARKETING-RISK",
employee_name="测试员工",
department_name="市场部",
expense_type="市场推广费",
reason="品牌投放活动",
amount=Decimal("20000.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
)
manifest = {
"applies_to": {
"domains": ["expense"],
"expense_types": ["marketing"],
}
}
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
manifest,
claim=claim,
contexts=[],
)
manifest["applies_to"]["expense_types"] = ["software"]
assert not PlatformRiskProbe()._risk_manifest_applies_to_claim(
manifest,
claim=claim,
contexts=[],
)
def test_platform_risk_all_expense_scope_matches_any_budget_category() -> None:
class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
pass
claim = ExpenseClaim(
claim_no="TEST-COMMUNICATION-RISK",
employee_name="测试员工",
department_name="市场部",
expense_type="通信费",
reason="客户支持电话费",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
)
manifest = {
"applies_to": {
"domains": ["expense"],
"expense_types": ["all"],
}
}
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
manifest,
claim=claim,
contexts=[],
)
manifest["applies_to"] = {"domains": ["expense"], "expense_categories": ["全部"]}
assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
manifest,
claim=claim,
contexts=[],
)
def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
renderer = RiskRuleFlowDiagramRenderer()
def render(severity: str, label: str) -> str:
return renderer.render(
RiskRuleFlowDiagramSpec(
title="测试规则",
domain_label="报销",
severity=severity,
severity_label=label,
fields=(),
start="业务单据提交",
evidence="读取规则字段",
decision="判断是否命中风险",
basis="根据规则字段判断",
pass_text="未命中风险,继续流转",
fail_text=f"命中{label},进入复核",
)
)
assert "#2563eb" in render("low", "低风险")
assert "#f97316" in render("medium", "中风险")
high_svg = render("high", "高风险")
assert "#dc2626" in high_svg
assert high_svg.count("#dc2626") >= 1
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)
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_travel_route_city_consistency_allows_normal_round_trip_to_declared_destination() -> 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": ["绕行", "跨城办事", "临时改签"],
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = ExpenseClaim(
claim_no="TEST-ROUND-TRIP",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去上海支撑项目部署",
location="上海",
amount=Decimal("708.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-ROUND-TRIP-EMP",
name="测试员工",
email="round-trip@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去上海支撑项目部署",
item_location="上海",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "武汉-上海"},
],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
},
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "上海-武汉"},
],
},
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
},
],
)
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 = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
"若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,则标记为高风险,"
"要求补充行程说明或退回修改。"
)
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" not 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_does_not_use_employee_location_as_allowed_endpoint() -> 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=申报城市,"
"A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。"
),
},
"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["unexpected_route_cities"] == ["北京", "武汉"]
assert "home_values" not in evidence
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")
generator = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = generator.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
risk_level="high",
natural_language="酒店发票城市必须与行程城市一致,不一致时标记高风险。",
),
actor="pytest",
)
service = AgentAssetService(db)
service.rule_library_manager = manager
asset = db.get(AgentAsset, asset_id)
assert asset is not None
try:
service.create_review(
asset_id,
AgentAssetReviewCreate(
version=asset.working_version or "v0.1.0",
reviewer="manager",
review_status=AgentReviewStatus.PENDING,
review_note="送审",
),
actor="pytest",
)
except PermissionError as exc:
assert "测试通过" in str(exc)
else:
raise AssertionError("未测试通过的风险规则不应允许提交审核")
simulation = service.simulate_risk_rule_message(
asset_id,
AgentAssetRiskRuleSimulationRequest(
message="我想仿真一张酒店报销单酒店发票城市上海申报目的地北京金额580元。",
),
)
assert simulation.execution_mode == "risk_rule_simulation"
assert simulation.ready is True
assert simulation.hit is True
assert simulation.severity == "high"
assert "不创建业务单据" in simulation.summary
assert service.get_latest_risk_rule_test_summary(asset_id).sample is None
blocked_simulation = service.simulate_risk_rule_message(
asset_id,
AgentAssetRiskRuleSimulationRequest(
message="请识别上传单据是否命中风险规则。",
attachments=[{"name": "hotel-invoice.pdf", "content_type": "application/pdf"}],
),
)
assert blocked_simulation.ready is False
assert blocked_simulation.stage == "needs_recognition"
assert blocked_simulation.hit is False
assert "尚未完成识别" in blocked_simulation.summary
db.add(
ExpenseClaim(
claim_no="TEST-CLAIM-001",
employee_name="张三",
department_name="财务部",
expense_type="住宿费",
reason="北京出差住宿",
location="北京",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime.now(UTC),
created_at=datetime.now(UTC),
status="draft",
)
)
db.commit()
sample = service.run_risk_rule_sample_test(
asset_id,
AgentAssetRiskRuleSampleTestRequest(),
actor="pytest",
)
assert sample.passed is True
scenario = service.run_risk_rule_scenario_test(
asset_id,
AgentAssetRiskRuleScenarioTestRequest(intent="用最近30天的住宿报销单试运行"),
actor="pytest",
)
assert scenario.passed is True
assert scenario.result_json["total_count"] == 1
report = service.confirm_risk_rule_test_report(
asset_id,
AgentAssetRiskRuleReportRequest(confirm_passed=True),
actor="pytest",
)
assert report.passed is True
review = service.create_review(
asset_id,
AgentAssetReviewCreate(
version=asset.working_version or "v0.1.0",
reviewer="manager",
review_status=AgentReviewStatus.PENDING,
review_note="送审",
),
actor="pytest",
)
assert review.review_status == AgentReviewStatus.PENDING.value
published = service.publish_risk_rule(asset_id, actor="manager")
assert published.status == AgentAssetStatus.ACTIVE.value
assert published.published_version == asset.working_version
disabled = service.set_risk_rule_enabled(
asset_id,
enabled=False,
actor="manager",
)
assert disabled.status == AgentAssetStatus.DISABLED.value
assert disabled.published_version == asset.working_version
assert disabled.config_json["enabled"] is False
assert disabled.config_json["last_operation"]["action"] == "offline"
rule_document = disabled.config_json["rule_document"]
manifest = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=rule_document["file_name"],
)
assert manifest["enabled"] is False
enabled = service.set_risk_rule_enabled(asset_id, enabled=True, actor="manager")
assert enabled.status == AgentAssetStatus.ACTIVE.value
assert enabled.config_json["enabled"] is True
assert enabled.config_json["last_operation"]["action"] == "online"
attachment_required_id = generator.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
risk_level="medium",
natural_language="发票号码不能为空,缺失时进入中风险复核。",
requires_attachment=True,
),
actor="pytest",
)
attachment_required_asset = db.get(AgentAsset, attachment_required_id)
assert attachment_required_asset is not None
assert attachment_required_asset.config_json["requires_attachment"] is True
attachment_rule_document = attachment_required_asset.config_json["rule_document"]
attachment_manifest = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=attachment_rule_document["file_name"],
)
assert attachment_manifest["requires_attachment"] is True
no_attachment_simulation = service.simulate_risk_rule_message(
attachment_required_id,
AgentAssetRiskRuleSimulationRequest(message="请测试这条规则。"),
)
assert no_attachment_simulation.ready is False
assert no_attachment_simulation.stage == "needs_attachment"
attachment_only_simulation = service.simulate_risk_rule_message(
attachment_required_id,
AgentAssetRiskRuleSimulationRequest(
message="请识别上传单据是否命中风险规则。",
attachments=[
{
"name": "invoice.pdf",
"content_type": "application/pdf",
"document_fields": [
{"key": "invoice_no", "label": "发票号码", "value": "INV-001"}
],
}
],
),
)
assert attachment_only_simulation.ready is False
assert attachment_only_simulation.stage == "needs_test_intent"
def test_delete_unpublished_risk_rule_removes_asset_and_json_file(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
asset_id = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=NullRuntimeChatService(),
).generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
risk_level="medium",
natural_language="报销事由不能为空,缺失时进入中风险复核。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
file_name = asset.config_json["rule_document"]["file_name"]
rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
assert rule_path.exists()
service = AgentAssetService(db)
service.rule_library_manager = manager
service.delete_unpublished_asset(asset_id, actor="pytest")
assert db.get(AgentAsset, asset_id) is None
assert not rule_path.exists()