""" 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"])