后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。
994 lines
41 KiB
Python
994 lines
41 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import UTC, date, datetime
|
||
from decimal import Decimal
|
||
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, 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_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",
|
||
"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:",
|
||
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_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_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中出现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")
|
||
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()
|