feat: 新增预算中心本体与风险规则评分回填

后端新增预算本体解析模块和风险规则评分回填服务,优化规则
生成本体对齐和提示词构建,增强费用类型关键词和本体验证,
完善报销查询和审计接口,前端预算中心页面增加对话框和本
体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 12:16:20 +08:00
parent 0e861d8fa6
commit e1e515ecae
53 changed files with 4350 additions and 921 deletions

View File

@@ -3,7 +3,9 @@ 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
@@ -32,6 +34,7 @@ from app.services.risk_rule_flow_diagram import (
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
@@ -113,6 +116,8 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
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"
@@ -122,9 +127,15 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
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("住宿城市")
@@ -147,7 +158,141 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
assert "feDropShadow" not in payload["flow_diagram_svg"]
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
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(
@@ -169,31 +314,16 @@ def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> N
asset_service = AgentAssetService(db)
asset_service.rule_library_manager = manager
updated = asset_service.set_risk_rule_level(
asset_id,
risk_level="low",
actor="pytest",
)
with pytest.raises(ValueError, match="评分模型"):
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
assert asset.config_json["severity"] != "low"
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
@@ -774,7 +904,10 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
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,
@@ -782,6 +915,11 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
)
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,