Files
X-Financial/server/tests/test_risk_rule_generation.py
caoxiaozhu 0e861d8fa6 feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
2026-05-26 09:15:14 +08:00

856 lines
35 KiB
Python
Raw 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 sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
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.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.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:
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",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地A与B无交集且无合理说明或A出现BC之外城市时命中。"
),
"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.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["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="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_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()
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_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中出现BC之外城市则命中。",
},
"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")
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.config_json["enabled"] is False
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
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()