329 lines
11 KiB
Python
329 lines
11 KiB
Python
|
|
"""
|
|||
|
|
Test cases for Step-KB Binding feature.
|
|||
|
|
[Step-KB-Binding] 步骤关联知识库功能的测试用例
|
|||
|
|
|
|||
|
|
测试覆盖:
|
|||
|
|
1. 步骤配置的增删改查与参数校验
|
|||
|
|
2. 配置步骤KB范围后,检索仅在范围内发生
|
|||
|
|
3. 未配置时回退原逻辑
|
|||
|
|
4. 多知识库同名内容场景下,步骤约束生效
|
|||
|
|
5. trace 字段完整性校验
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestStepKbBindingModel:
|
|||
|
|
"""测试步骤KB绑定数据模型"""
|
|||
|
|
|
|||
|
|
def test_flow_step_with_kb_binding_fields(self):
|
|||
|
|
"""测试 FlowStep 包含 KB 绑定字段"""
|
|||
|
|
from app.models.entities import FlowStep
|
|||
|
|
|
|||
|
|
step = FlowStep(
|
|||
|
|
step_no=1,
|
|||
|
|
content="测试步骤",
|
|||
|
|
allowed_kb_ids=["kb-1", "kb-2"],
|
|||
|
|
preferred_kb_ids=["kb-1"],
|
|||
|
|
kb_query_hint="查找产品相关信息",
|
|||
|
|
max_kb_calls_per_step=2,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert step.allowed_kb_ids == ["kb-1", "kb-2"]
|
|||
|
|
assert step.preferred_kb_ids == ["kb-1"]
|
|||
|
|
assert step.kb_query_hint == "查找产品相关信息"
|
|||
|
|
assert step.max_kb_calls_per_step == 2
|
|||
|
|
|
|||
|
|
def test_flow_step_without_kb_binding(self):
|
|||
|
|
"""测试 FlowStep 不配置 KB 绑定时的默认值"""
|
|||
|
|
from app.models.entities import FlowStep
|
|||
|
|
|
|||
|
|
step = FlowStep(
|
|||
|
|
step_no=1,
|
|||
|
|
content="测试步骤",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert step.allowed_kb_ids is None
|
|||
|
|
assert step.preferred_kb_ids is None
|
|||
|
|
assert step.kb_query_hint is None
|
|||
|
|
assert step.max_kb_calls_per_step is None
|
|||
|
|
|
|||
|
|
def test_max_kb_calls_validation(self):
|
|||
|
|
"""测试 max_kb_calls_per_step 的范围校验"""
|
|||
|
|
from app.models.entities import FlowStep
|
|||
|
|
from pydantic import ValidationError
|
|||
|
|
|
|||
|
|
# 有效范围 1-5
|
|||
|
|
step = FlowStep(step_no=1, content="test", max_kb_calls_per_step=3)
|
|||
|
|
assert step.max_kb_calls_per_step == 3
|
|||
|
|
|
|||
|
|
# 超出上限
|
|||
|
|
with pytest.raises(Exception): # ValidationError
|
|||
|
|
FlowStep(step_no=1, content="test", max_kb_calls_per_step=10)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestStepKbConfig:
|
|||
|
|
"""测试 StepKbConfig 数据类"""
|
|||
|
|
|
|||
|
|
def test_step_kb_config_creation(self):
|
|||
|
|
"""测试 StepKbConfig 创建"""
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import StepKbConfig
|
|||
|
|
|
|||
|
|
config = StepKbConfig(
|
|||
|
|
allowed_kb_ids=["kb-1", "kb-2"],
|
|||
|
|
preferred_kb_ids=["kb-1"],
|
|||
|
|
kb_query_hint="查找产品信息",
|
|||
|
|
max_kb_calls=2,
|
|||
|
|
step_id="flow-1_step_1",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert config.allowed_kb_ids == ["kb-1", "kb-2"]
|
|||
|
|
assert config.preferred_kb_ids == ["kb-1"]
|
|||
|
|
assert config.kb_query_hint == "查找产品信息"
|
|||
|
|
assert config.max_kb_calls == 2
|
|||
|
|
assert config.step_id == "flow-1_step_1"
|
|||
|
|
|
|||
|
|
def test_step_kb_config_defaults(self):
|
|||
|
|
"""测试 StepKbConfig 默认值"""
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import StepKbConfig
|
|||
|
|
|
|||
|
|
config = StepKbConfig()
|
|||
|
|
|
|||
|
|
assert config.allowed_kb_ids is None
|
|||
|
|
assert config.preferred_kb_ids is None
|
|||
|
|
assert config.kb_query_hint is None
|
|||
|
|
assert config.max_kb_calls == 1
|
|||
|
|
assert config.step_id is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestKbSearchDynamicToolWithStepConfig:
|
|||
|
|
"""测试 KbSearchDynamicTool 与步骤配置的集成"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_kb_search_with_allowed_kb_ids(self):
|
|||
|
|
"""测试配置 allowed_kb_ids 后检索范围受限"""
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import (
|
|||
|
|
KbSearchDynamicTool,
|
|||
|
|
KbSearchDynamicConfig,
|
|||
|
|
StepKbConfig,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
mock_session = MagicMock()
|
|||
|
|
mock_timeout_governor = MagicMock()
|
|||
|
|
|
|||
|
|
tool = KbSearchDynamicTool(
|
|||
|
|
session=mock_session,
|
|||
|
|
timeout_governor=mock_timeout_governor,
|
|||
|
|
config=KbSearchDynamicConfig(enabled=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
step_config = StepKbConfig(
|
|||
|
|
allowed_kb_ids=["kb-allowed-1", "kb-allowed-2"],
|
|||
|
|
step_id="test_step",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with patch.object(tool, '_do_retrieve', new_callable=AsyncMock) as mock_retrieve:
|
|||
|
|
mock_retrieve.return_value = [
|
|||
|
|
{"id": "1", "content": "test", "score": 0.8, "metadata": {"kb_id": "kb-allowed-1"}}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
result = await tool.execute(
|
|||
|
|
query="测试查询",
|
|||
|
|
tenant_id="tenant-1",
|
|||
|
|
step_kb_config=step_config,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 验证检索调用时传入了正确的 kb_ids
|
|||
|
|
call_args = mock_retrieve.call_args
|
|||
|
|
assert call_args[1]['step_kb_config'] == step_config
|
|||
|
|
|
|||
|
|
# 验证返回结果包含 step_kb_binding 信息
|
|||
|
|
assert result.step_kb_binding is not None
|
|||
|
|
assert result.step_kb_binding['allowed_kb_ids'] == ["kb-allowed-1", "kb-allowed-2"]
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_kb_search_without_step_config(self):
|
|||
|
|
"""测试未配置步骤KB时的回退行为"""
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import (
|
|||
|
|
KbSearchDynamicTool,
|
|||
|
|
KbSearchDynamicConfig,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
mock_session = MagicMock()
|
|||
|
|
mock_timeout_governor = MagicMock()
|
|||
|
|
|
|||
|
|
tool = KbSearchDynamicTool(
|
|||
|
|
session=mock_session,
|
|||
|
|
timeout_governor=mock_timeout_governor,
|
|||
|
|
config=KbSearchDynamicConfig(enabled=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with patch.object(tool, '_do_retrieve', new_callable=AsyncMock) as mock_retrieve:
|
|||
|
|
mock_retrieve.return_value = [
|
|||
|
|
{"id": "1", "content": "test", "score": 0.8, "metadata": {}}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
result = await tool.execute(
|
|||
|
|
query="测试查询",
|
|||
|
|
tenant_id="tenant-1",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 验证检索调用时未传入 step_kb_config
|
|||
|
|
call_args = mock_retrieve.call_args
|
|||
|
|
assert call_args[1]['step_kb_config'] is None
|
|||
|
|
|
|||
|
|
# 验证返回结果不包含 step_kb_binding
|
|||
|
|
assert result.step_kb_binding is None
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_kb_search_result_includes_used_kb_ids(self):
|
|||
|
|
"""测试检索结果包含实际使用的知识库ID"""
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import (
|
|||
|
|
KbSearchDynamicTool,
|
|||
|
|
KbSearchDynamicConfig,
|
|||
|
|
StepKbConfig,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
mock_session = MagicMock()
|
|||
|
|
mock_timeout_governor = MagicMock()
|
|||
|
|
|
|||
|
|
tool = KbSearchDynamicTool(
|
|||
|
|
session=mock_session,
|
|||
|
|
timeout_governor=mock_timeout_governor,
|
|||
|
|
config=KbSearchDynamicConfig(enabled=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
step_config = StepKbConfig(
|
|||
|
|
allowed_kb_ids=["kb-1", "kb-2"],
|
|||
|
|
step_id="test_step",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with patch.object(tool, '_do_retrieve', new_callable=AsyncMock) as mock_retrieve:
|
|||
|
|
mock_retrieve.return_value = [
|
|||
|
|
{"id": "1", "content": "test1", "score": 0.9, "metadata": {"kb_id": "kb-1"}},
|
|||
|
|
{"id": "2", "content": "test2", "score": 0.8, "metadata": {"kb_id": "kb-1"}},
|
|||
|
|
{"id": "3", "content": "test3", "score": 0.7, "metadata": {"kb_id": "kb-2"}},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
result = await tool.execute(
|
|||
|
|
query="测试查询",
|
|||
|
|
tenant_id="tenant-1",
|
|||
|
|
step_kb_config=step_config,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 验证 used_kb_ids 包含所有命中的知识库
|
|||
|
|
assert result.step_kb_binding is not None
|
|||
|
|
assert set(result.step_kb_binding['used_kb_ids']) == {"kb-1", "kb-2"}
|
|||
|
|
assert result.step_kb_binding['kb_hit'] is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestTraceInfoStepKbBinding:
|
|||
|
|
"""测试 TraceInfo 中的 step_kb_binding 字段"""
|
|||
|
|
|
|||
|
|
def test_trace_info_with_step_kb_binding(self):
|
|||
|
|
"""测试 TraceInfo 包含 step_kb_binding 字段"""
|
|||
|
|
from app.models.mid.schemas import TraceInfo, ExecutionMode
|
|||
|
|
|
|||
|
|
trace = TraceInfo(
|
|||
|
|
mode=ExecutionMode.AGENT,
|
|||
|
|
step_kb_binding={
|
|||
|
|
"step_id": "flow-1_step_2",
|
|||
|
|
"allowed_kb_ids": ["kb-1", "kb-2"],
|
|||
|
|
"used_kb_ids": ["kb-1"],
|
|||
|
|
"kb_hit": True,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert trace.step_kb_binding is not None
|
|||
|
|
assert trace.step_kb_binding['step_id'] == "flow-1_step_2"
|
|||
|
|
assert trace.step_kb_binding['allowed_kb_ids'] == ["kb-1", "kb-2"]
|
|||
|
|
assert trace.step_kb_binding['used_kb_ids'] == ["kb-1"]
|
|||
|
|
|
|||
|
|
def test_trace_info_without_step_kb_binding(self):
|
|||
|
|
"""测试 TraceInfo 默认不包含 step_kb_binding"""
|
|||
|
|
from app.models.mid.schemas import TraceInfo, ExecutionMode
|
|||
|
|
|
|||
|
|
trace = TraceInfo(mode=ExecutionMode.AGENT)
|
|||
|
|
|
|||
|
|
assert trace.step_kb_binding is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestFlowStepKbBindingIntegration:
|
|||
|
|
"""测试流程步骤与KB绑定的集成"""
|
|||
|
|
|
|||
|
|
def test_script_flow_steps_with_kb_binding(self):
|
|||
|
|
"""测试 ScriptFlow 的 steps 包含 KB 绑定配置"""
|
|||
|
|
from app.models.entities import ScriptFlowCreate
|
|||
|
|
|
|||
|
|
flow_create = ScriptFlowCreate(
|
|||
|
|
name="测试流程",
|
|||
|
|
steps=[
|
|||
|
|
{
|
|||
|
|
"step_no": 1,
|
|||
|
|
"content": "步骤1",
|
|||
|
|
"allowed_kb_ids": ["kb-1"],
|
|||
|
|
"preferred_kb_ids": None,
|
|||
|
|
"kb_query_hint": "查找产品信息",
|
|||
|
|
"max_kb_calls_per_step": 2,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"step_no": 2,
|
|||
|
|
"content": "步骤2",
|
|||
|
|
# 不配置 KB 绑定
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert flow_create.steps[0]['allowed_kb_ids'] == ["kb-1"]
|
|||
|
|
assert flow_create.steps[0]['kb_query_hint'] == "查找产品信息"
|
|||
|
|
assert flow_create.steps[1].get('allowed_kb_ids') is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestKbBindingLogging:
|
|||
|
|
"""测试 KB 绑定的日志记录"""
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_step_kb_config_logging(self, caplog):
|
|||
|
|
"""测试步骤KB配置的日志记录"""
|
|||
|
|
import logging
|
|||
|
|
from app.services.mid.kb_search_dynamic_tool import (
|
|||
|
|
KbSearchDynamicTool,
|
|||
|
|
KbSearchDynamicConfig,
|
|||
|
|
StepKbConfig,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
mock_session = MagicMock()
|
|||
|
|
mock_timeout_governor = MagicMock()
|
|||
|
|
|
|||
|
|
tool = KbSearchDynamicTool(
|
|||
|
|
session=mock_session,
|
|||
|
|
timeout_governor=mock_timeout_governor,
|
|||
|
|
config=KbSearchDynamicConfig(enabled=True),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
step_config = StepKbConfig(
|
|||
|
|
allowed_kb_ids=["kb-1"],
|
|||
|
|
step_id="flow-1_step_1",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with patch.object(tool, '_do_retrieve', new_callable=AsyncMock) as mock_retrieve:
|
|||
|
|
mock_retrieve.return_value = []
|
|||
|
|
|
|||
|
|
with caplog.at_level(logging.INFO):
|
|||
|
|
await tool.execute(
|
|||
|
|
query="测试",
|
|||
|
|
tenant_id="tenant-1",
|
|||
|
|
step_kb_config=step_config,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 验证日志包含 Step-KB-Binding 标记
|
|||
|
|
assert any("Step-KB-Binding" in record.message for record in caplog.records)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 运行测试的入口
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
pytest.main([__file__, "-v"])
|