feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
@@ -8,8 +8,10 @@ from app.services.knowledge_ingest_log import (
|
||||
build_document_graph_summary,
|
||||
build_ingest_document_summary,
|
||||
build_ingest_status_summary,
|
||||
enrich_knowledge_ingest_route_json,
|
||||
)
|
||||
from app.services.knowledge_rag import KnowledgeRagService
|
||||
from app.services.knowledge_rag_local import query_local_text_chunks
|
||||
|
||||
|
||||
def test_build_hits_prioritizes_structured_table_evidence_for_standard_queries() -> None:
|
||||
@@ -82,6 +84,84 @@ def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
|
||||
assert [item["candidate_id"] for item in hits] == ["clue-1", "plain-1"]
|
||||
|
||||
|
||||
def test_query_local_text_chunks_prioritizes_relevant_policy_chunk(tmp_path) -> None:
|
||||
workspace = tmp_path / "knowledge" / ".lightrag" / "x_financial_knowledge"
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / "kv_store_text_chunks.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"chunk-travel": {
|
||||
"_id": "chunk-travel",
|
||||
"full_doc_id": "doc-1",
|
||||
"chunk_order_index": 1,
|
||||
"file_path": "/tmp/doc-1__差旅费管理办法.pdf",
|
||||
"content": (
|
||||
"第十三条 差旅费。酒店住宿限额标准如下:其他员工直辖市350元、"
|
||||
"省会城市300元、其他地区250元。确因紧急公务、特别情形等事项"
|
||||
"导致住宿超过规定标准时,超标20%以内由部门负责人审批,"
|
||||
"超标20%以上需分管领导审批。"
|
||||
),
|
||||
},
|
||||
"chunk-office": {
|
||||
"_id": "chunk-office",
|
||||
"full_doc_id": "doc-2",
|
||||
"chunk_order_index": 1,
|
||||
"file_path": "/tmp/doc-2__办公用品管理办法.pdf",
|
||||
"content": "办公用品采购应遵循预算和验收流程。",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = query_local_text_chunks(
|
||||
lightrag_root=tmp_path / "knowledge" / ".lightrag",
|
||||
workspace="x_financial_knowledge",
|
||||
query="住宿费超过标准审批依据是什么?",
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert result.confident is True
|
||||
assert result.hits[0]["candidate_id"] == "chunk-travel"
|
||||
assert "住宿超过规定标准" in result.hits[0]["content"]
|
||||
|
||||
|
||||
def test_query_knowledge_uses_local_chunks_before_lightrag_runtime(tmp_path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "knowledge" / ".lightrag" / "x_financial_knowledge"
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / "kv_store_text_chunks.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"chunk-1": {
|
||||
"_id": "chunk-1",
|
||||
"full_doc_id": "doc-1",
|
||||
"chunk_order_index": 1,
|
||||
"file_path": "/tmp/doc-1__公司支出管理办法.pdf",
|
||||
"content": (
|
||||
"第八条 支出报销申请时限。公司各类支出报销结算申请时限为三个月。"
|
||||
"逾期需说明原因,经分管领导审批后方可报销。"
|
||||
),
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fail_if_runtime_is_used(_self):
|
||||
raise AssertionError("local high-confidence queries should not initialize LightRAG")
|
||||
|
||||
monkeypatch.setattr(KnowledgeRagService, "_get_runtime", fail_if_runtime_is_used)
|
||||
|
||||
payload = KnowledgeRagService(storage_root=tmp_path).query_knowledge(
|
||||
"费用发生后多久内必须报销?超过三个月还能不能报?",
|
||||
limit=3,
|
||||
)
|
||||
|
||||
assert payload["record_count"] == 1
|
||||
assert payload["metadata"]["retrieval_strategy"] == "local_text_chunks"
|
||||
assert "三个月" in payload["hits"][0]["content"]
|
||||
|
||||
|
||||
def test_build_hits_demotes_chapter_navigation_for_specific_rule_queries() -> None:
|
||||
hits = KnowledgeRagService._build_hits_from_query_data(
|
||||
query="探亲差旅归哪个部门管理?",
|
||||
@@ -227,6 +307,46 @@ def test_build_document_graph_summary_reads_lightrag_storage(tmp_path) -> None:
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace / "kv_store_entity_chunks.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"远光软件": {"chunk_ids": ["chunk-1", "chunk-missing"]},
|
||||
"支出管理": {"chunk_ids": ["chunk-2"]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace / "graph_chunk_entity_relation.graphml").write_text(
|
||||
"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<graphml xmlns="http://graphml.graphdrawing.org/xmlns">
|
||||
<key id="n0" for="node" attr.name="entity_id" attr.type="string" />
|
||||
<key id="n1" for="node" attr.name="entity_type" attr.type="string" />
|
||||
<key id="n2" for="node" attr.name="description" attr.type="string" />
|
||||
<key id="n3" for="node" attr.name="created_at" attr.type="string" />
|
||||
<key id="e0" for="edge" attr.name="weight" attr.type="double" />
|
||||
<key id="e1" for="edge" attr.name="description" attr.type="string" />
|
||||
<key id="e2" for="edge" attr.name="keywords" attr.type="string" />
|
||||
<graph edgedefault="undirected">
|
||||
<node id="远光软件">
|
||||
<data key="n0">远光软件</data>
|
||||
<data key="n1">ORGANIZATION</data>
|
||||
<data key="n2">公司主体<SEP>费用制度适用公司</data>
|
||||
<data key="n3">2026-05-23</data>
|
||||
</node>
|
||||
<node id="支出管理">
|
||||
<data key="n0">支出管理</data>
|
||||
<data key="n1">TOPIC</data>
|
||||
<data key="n2">规范费用支出、预算和审批。</data>
|
||||
</node>
|
||||
<edge source="远光软件" target="支出管理">
|
||||
<data key="e0">2.5</data>
|
||||
<data key="e1">远光软件通过支出管理制度约束费用审批。</data>
|
||||
<data key="e2">制度<SEP>审批</data>
|
||||
</edge>
|
||||
</graph>
|
||||
</graphml>""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
summary = build_document_graph_summary(
|
||||
tmp_path,
|
||||
@@ -235,10 +355,39 @@ def test_build_document_graph_summary_reads_lightrag_storage(tmp_path) -> None:
|
||||
)
|
||||
|
||||
assert summary["entity_count"] == 2
|
||||
assert summary["entities"] == ["远光软件", "支出管理"]
|
||||
assert [item["name"] for item in summary["entities"]] == ["远光软件", "支出管理"]
|
||||
assert summary["entities"][0]["type"] == "ORGANIZATION"
|
||||
assert summary["entities"][0]["descriptions"][0] == "公司主体"
|
||||
assert summary["relation_count"] == 1
|
||||
assert summary["relations"] == [{"source": "远光软件", "target": "支出管理", "type": "关联"}]
|
||||
assert summary["relations"][0]["source"] == "远光软件"
|
||||
assert summary["relations"][0]["target"] == "支出管理"
|
||||
assert summary["relations"][0]["description"] == "远光软件通过支出管理制度约束费用审批。"
|
||||
assert summary["relations"][0]["keywords"] == ["制度", "审批"]
|
||||
assert summary["relations"][0]["weight"] == 2.5
|
||||
assert [item["id"] for item in summary["chunks"]] == ["chunk-1", "chunk-2"]
|
||||
assert summary["chunks"][0]["excerpt"].startswith("第一条")
|
||||
assert summary["entity_chunks"] == [
|
||||
{"entity": "远光软件", "chunk_ids": ["chunk-1"]},
|
||||
{"entity": "支出管理", "chunk_ids": ["chunk-2"]},
|
||||
]
|
||||
|
||||
enriched_route = enrich_knowledge_ingest_route_json(
|
||||
{
|
||||
"lightrag_workspace": "test_workspace",
|
||||
"knowledge_ingest": {
|
||||
"graph": {
|
||||
"entities": ["远光软件"],
|
||||
"relations": [
|
||||
{"source": "远光软件", "target": "支出管理", "type": "关联"}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
storage_root=tmp_path,
|
||||
)
|
||||
enriched_entities = enriched_route["knowledge_ingest"]["graph"]["entities"]
|
||||
assert [item["name"] for item in enriched_entities] == ["远光软件", "支出管理"]
|
||||
assert enriched_entities[1]["type"] == "TOPIC"
|
||||
|
||||
|
||||
def test_build_ingest_document_summary_extracts_sections() -> None:
|
||||
|
||||
106
server/tests/test_risk_rule_generation.py
Normal file
106
server/tests/test_risk_rule_generation.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
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.db.base import Base
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
||||
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.risk_rule_generation import RiskRuleGenerationService
|
||||
|
||||
|
||||
class NullRuntimeChatService:
|
||||
def complete(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
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,
|
||||
risk_level="high",
|
||||
natural_language="住宿城市必须出现在本次差旅行程城市中,否则提示高风险。",
|
||||
),
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
asset = db.get(AgentAsset, asset_id)
|
||||
assert asset is not None
|
||||
assert asset.status == AgentAssetStatus.DRAFT.value
|
||||
assert asset.config_json["detail_mode"] == "json_risk"
|
||||
assert asset.config_json["evaluator"] == "template_rule"
|
||||
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["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="760" height="280"' in payload["flow_diagram_svg"]
|
||||
assert 'data-risk-flow-style="review-node-only"' 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 "#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_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
|
||||
Reference in New Issue
Block a user