feat: 新增预算中心本体与风险规则评分回填
后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
@@ -3,16 +3,15 @@ from __future__ import annotations
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -25,9 +24,11 @@ def build_session_factory() -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
session_factory = build_session_factory()
|
||||
app = create_app()
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
session_factory = build_session_factory()
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
@@ -253,13 +254,113 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "query"
|
||||
assert result.time_range.start_date == "2026-04-01"
|
||||
assert result.time_range.end_date == "2026-04-30"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_budget_query_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="查询 CC-4100 2026年度差旅费可用预算和预算占用",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
metric_names = {item.name for item in result.metrics}
|
||||
|
||||
assert result.scenario == "budget"
|
||||
assert result.intent == "query"
|
||||
assert entity_map["cost_center"] == "CC-4100"
|
||||
assert entity_map["budget_period"] == "2026年度"
|
||||
assert entity_map["budget_subject"] == "travel"
|
||||
assert entity_map["expense_type"] == "travel"
|
||||
assert {"available_amount", "reserved_amount"}.issubset(metric_names)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_budget_edit_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="编辑预算:2026年度 CC-4100 差旅费预算金额60万元,预警线80%,控制动作提醒",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
"document_type": "budget_plan",
|
||||
"entry_source": "budget_center",
|
||||
"conversation_scenario": "budget",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
|
||||
assert result.scenario == "budget"
|
||||
assert result.intent == "draft"
|
||||
assert result.permission.level == "draft_write"
|
||||
assert entity_map["budget_period"] == "2026年度"
|
||||
assert entity_map["budget_subject"] == "travel"
|
||||
assert entity_map["expense_type"] == "travel"
|
||||
assert entity_map["budget_amount"] == "600000"
|
||||
assert entity_map["warning_threshold"] == "80%"
|
||||
assert entity_map["control_action"] == "remind"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_quarter_budget_period() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="查询 CC-4100 2026年Q3 住宿费预算金额",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||
|
||||
assert result.scenario == "budget"
|
||||
assert entity_map["budget_period"] == "2026年Q3"
|
||||
assert entity_map["budget_subject"] == "hotel"
|
||||
assert entity_map["expense_type"] == "hotel"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,expected_code,expected_label",
|
||||
[
|
||||
("查询2026年度市场推广费预算余额", "marketing", "市场推广费"),
|
||||
("查看2026年度软件服务费已占用金额", "software", "软件服务费"),
|
||||
("统计2026年度业务招待费预算金额", "meal", "业务招待费"),
|
||||
],
|
||||
)
|
||||
def test_semantic_ontology_service_links_budget_subject_to_expense_type(
|
||||
query: str,
|
||||
expected_code: str,
|
||||
expected_label: str,
|
||||
) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(query=query, user_id="pytest")
|
||||
)
|
||||
|
||||
assert result.scenario == "budget"
|
||||
assert any(
|
||||
item.type == "budget_subject" and item.normalized_value == expected_code
|
||||
for item in result.entities
|
||||
)
|
||||
assert any(
|
||||
item.type == "expense_type"
|
||||
and item.normalized_value == expected_code
|
||||
and item.value == expected_label
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user