feat: 增强规则资产管理与审计页面运行时调试

后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险
规则生成模板执行和员工数据模型字段,知识库 RAG 增强本
地回退和文档提取能力,清理旧风险规则文件统一由生成引擎
管理,前端审计页面增加运行时调试面板和规则资产编辑交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-24 21:44:17 +08:00
parent 575f093c74
commit 50b1c3f9a9
113 changed files with 13896 additions and 5044 deletions

View File

@@ -1,18 +1,32 @@
from __future__ import annotations
import json
from datetime import UTC, 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
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentReviewStatus
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.models.financial_record import ExpenseClaim
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.risk_rule_flow_diagram import RiskRuleFlowDiagramRenderer, RiskRuleFlowDiagramSpec
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
)
from app.services.risk_rule_generation import RiskRuleGenerationService
@@ -43,6 +57,7 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
expense_category="travel",
risk_level="high",
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
),
@@ -54,12 +69,18 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
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["applies_to"]["expense_categories"] == ["travel"]
assert payload["risk_category"] == "差旅费"
assert payload["metadata"]["expense_category"] == "travel"
assert payload["outcomes"]["fail"]["severity"] == "high"
assert payload["template_key"] == "field_compare_v1"
assert payload["metadata"]["natural_language"].startswith("住宿城市")
@@ -104,3 +125,206 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
assert "#dc2626" in high_svg
assert high_svg.count("#dc2626") == 1
assert "#10a37f" not in high_svg
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()