"""Intent Router 单元测试 - 两级意图路由:关键词匹配 → LLM 分类""" import json from unittest.mock import AsyncMock, MagicMock import pytest from agentkit.llm.protocol import LLMResponse, TokenUsage from agentkit.router import IntentRouter, RoutingResult from agentkit.skills.base import IntentConfig, Skill, SkillConfig # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_skill( name: str, keywords: list[str] | None = None, description: str = "", examples: list[str] | None = None, ) -> Skill: """快速构造一个带 intent 配置的 Skill""" config = SkillConfig( name=name, agent_type="test", task_mode="llm_generate", prompt={"system": f"You are a {name} skill."}, intent={ "keywords": keywords or [], "description": description, "examples": examples or [], }, ) return Skill(config=config) def _make_llm_gateway(response_content: str) -> MagicMock: """构造一个 mock LLMGateway,chat 返回指定 content""" gateway = MagicMock() gateway.chat = AsyncMock( return_value=LLMResponse( content=response_content, model="test-model", usage=TokenUsage(prompt_tokens=10, completion_tokens=20), ) ) return gateway # --------------------------------------------------------------------------- # RoutingResult 数据类 # --------------------------------------------------------------------------- class TestRoutingResult: """RoutingResult 数据类基本验证""" def test_create_routing_result(self): result = RoutingResult(matched_skill="weather", method="keyword", confidence=1.0) assert result.matched_skill == "weather" assert result.method == "keyword" assert result.confidence == 1.0 def test_routing_result_contains_method_and_confidence(self): result = RoutingResult(matched_skill="search", method="llm", confidence=0.85) assert hasattr(result, "method") assert hasattr(result, "confidence") assert result.method == "llm" assert result.confidence == 0.85 # --------------------------------------------------------------------------- # 关键词匹配 (Level 1) # --------------------------------------------------------------------------- class TestKeywordMatching: """Level 1: 关键词匹配""" @pytest.mark.asyncio async def test_keyword_match_returns_keyword_method(self): """输入包含 Skill 的 intent.keywords → 返回 method='keyword', confidence=1.0""" router = IntentRouter() weather = _make_skill("weather", keywords=["天气", "weather", "气温"]) skills = [weather] result = await router.route({"query": "今天天气怎么样"}, skills) assert result.matched_skill == "weather" assert result.method == "keyword" assert result.confidence == 1.0 @pytest.mark.asyncio async def test_keyword_no_match_falls_through(self): """输入不包含任何 keyword → 关键词匹配返回 None,走 LLM""" gateway = _make_llm_gateway(json.dumps({"skill": "search", "confidence": 0.9})) router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"]) search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] result = await router.route({"query": "帮我找一下附近的餐厅"}, skills) # 应该走 LLM fallback assert result.method == "llm" assert result.matched_skill == "search" @pytest.mark.asyncio async def test_keyword_match_case_insensitive(self): """关键词匹配不区分大小写""" router = IntentRouter() skill = _make_skill("weather", keywords=["Weather", "TEMPERATURE"]) skills = [skill] result = await router.route({"query": "what's the weather today"}, skills) assert result.matched_skill == "weather" assert result.method == "keyword" assert result.confidence == 1.0 @pytest.mark.asyncio async def test_keyword_confidence_always_1(self): """关键词匹配的 confidence 始终为 1.0""" router = IntentRouter() skill = _make_skill("calc", keywords=["计算", "算数"]) skills = [skill] result = await router.route({"text": "帮我计算一下"}, skills) assert result.confidence == 1.0 @pytest.mark.asyncio async def test_keyword_match_nested_input(self): """关键词匹配检查 input_data 中的嵌套字符串值""" router = IntentRouter() skill = _make_skill("translate", keywords=["翻译", "translate"]) skills = [skill] result = await router.route( {"message": {"content": "请翻译这段话", "lang": "en"}}, skills, ) assert result.matched_skill == "translate" assert result.method == "keyword" @pytest.mark.asyncio async def test_keyword_match_multiple_hits_returns_first(self): """多个关键词匹配时,返回第一个匹配的 Skill""" router = IntentRouter() skill_a = _make_skill("weather", keywords=["天气"]) skill_b = _make_skill("translate", keywords=["翻译"]) skills = [skill_a, skill_b] # "天气" 先匹配 result = await router.route({"query": "天气翻译"}, skills) assert result.matched_skill == "weather" @pytest.mark.asyncio async def test_keyword_match_in_list_values(self): """关键词匹配检查 input_data 中列表内的字符串值""" router = IntentRouter() skill = _make_skill("search", keywords=["搜索"]) skills = [skill] result = await router.route( {"messages": ["你好", "帮我搜索一下"], "type": "chat"}, skills, ) assert result.matched_skill == "search" assert result.method == "keyword" # --------------------------------------------------------------------------- # LLM 分类 (Level 2) # --------------------------------------------------------------------------- class TestLLMClassification: """Level 2: LLM 分类""" @pytest.mark.asyncio async def test_llm_classification_returns_llm_method(self): """关键词匹配失败,LLM 正确分类 → 返回 method='llm'""" gateway = _make_llm_gateway(json.dumps({"skill": "search", "confidence": 0.92})) router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] result = await router.route({"query": "附近有什么好吃的"}, skills) assert result.matched_skill == "search" assert result.method == "llm" assert result.confidence == 0.92 @pytest.mark.asyncio async def test_llm_confidence_from_response(self): """LLM 分类的 confidence 来自 LLM 响应""" gateway = _make_llm_gateway(json.dumps({"skill": "weather", "confidence": 0.75})) router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] result = await router.route({"query": "外面冷不冷"}, skills) assert result.confidence == 0.75 @pytest.mark.asyncio async def test_llm_nonexistent_skill_raises_value_error(self): """LLM 返回不存在的 skill name → 抛出 ValueError""" gateway = _make_llm_gateway(json.dumps({"skill": "nonexistent", "confidence": 0.5})) router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] with pytest.raises(ValueError, match="nonexistent"): await router.route({"query": "你好"}, skills) @pytest.mark.asyncio async def test_llm_malformed_json_extracts_skill_name(self): """LLM 返回非标准 JSON → 尝试从文本中提取 skill name""" gateway = _make_llm_gateway('我觉得应该匹配 weather 这个技能') router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] result = await router.route({"query": "外面冷不冷"}, skills) # 应该能从文本中提取到 "weather" assert result.matched_skill == "weather" assert result.method == "llm" @pytest.mark.asyncio async def test_llm_no_gateway_raises_error(self): """没有 LLM Gateway 且关键词匹配失败 → 抛出异常""" router = IntentRouter(llm_gateway=None) weather = _make_skill("weather", keywords=["天气"]) search = _make_skill("search", keywords=["搜索"]) skills = [weather, search] with pytest.raises((ValueError, RuntimeError)): await router.route({"query": "你好世界"}, skills) @pytest.mark.asyncio async def test_llm_classification_uses_skill_description_and_examples(self): """LLM 分类时使用 Skill 的 description 和 examples 构建提示""" gateway = _make_llm_gateway(json.dumps({"skill": "search", "confidence": 0.9})) router = IntentRouter(llm_gateway=gateway) search = _make_skill( "search", keywords=["搜索"], description="搜索互联网上的信息", examples=["帮我搜一下", "查找相关资料"], ) weather = _make_skill("weather", keywords=["天气"], description="查询天气") skills = [search, weather] await router.route({"query": "找找看"}, skills) # 验证 LLM 被调用,且 prompt 包含 description 和 examples gateway.chat.assert_called_once() call_args = gateway.chat.call_args messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][0] prompt_text = messages[0]["content"] if isinstance(messages, list) else str(messages) assert "搜索互联网上的信息" in prompt_text assert "帮我搜一下" in prompt_text # --------------------------------------------------------------------------- # 边界情况 # --------------------------------------------------------------------------- class TestEdgeCases: """边界情况""" @pytest.mark.asyncio async def test_single_skill_returns_directly(self): """只有一个 Skill 时直接返回,不做关键词/LLM 检查""" router = IntentRouter() skill = _make_skill("only_one", keywords=["唯一"]) skills = [skill] result = await router.route({"query": "随便什么输入"}, skills) assert result.matched_skill == "only_one" assert result.method == "keyword" assert result.confidence == 1.0 @pytest.mark.asyncio async def test_empty_skill_list_raises_value_error(self): """空 Skill 列表 → 抛出 ValueError""" router = IntentRouter() with pytest.raises(ValueError, match="[Ss]kill"): await router.route({"query": "hello"}, []) @pytest.mark.asyncio async def test_skill_with_empty_keywords(self): """Skill 的 keywords 为空列表时,关键词匹配不会命中""" gateway = _make_llm_gateway(json.dumps({"skill": "generic", "confidence": 0.6})) router = IntentRouter(llm_gateway=gateway) skill = _make_skill("generic", keywords=[], description="通用技能") skills = [skill] result = await router.route({"query": "你好"}, skills) # 只有一个 skill,直接返回 assert result.matched_skill == "generic" @pytest.mark.asyncio async def test_input_data_with_no_string_values(self): """input_data 中没有字符串值 → 关键词匹配失败,走 LLM""" gateway = _make_llm_gateway(json.dumps({"skill": "weather", "confidence": 0.8})) router = IntentRouter(llm_gateway=gateway) weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] result = await router.route({"count": 42, "flag": True}, skills) assert result.method == "llm" @pytest.mark.asyncio async def test_model_parameter_passed_to_gateway(self): """IntentRouter 的 model 参数传递给 LLM Gateway""" gateway = _make_llm_gateway(json.dumps({"skill": "weather", "confidence": 0.9})) router = IntentRouter(llm_gateway=gateway, model="gpt-4") weather = _make_skill("weather", keywords=["天气"], description="查询天气") search = _make_skill("search", keywords=["搜索"], description="搜索信息") skills = [weather, search] await router.route({"query": "你好"}, skills) gateway.chat.assert_called_once() call_kwargs = gateway.chat.call_args[1] if gateway.chat.call_args[1] else {} assert call_kwargs.get("model") == "gpt-4" or gateway.chat.call_args[0][1] == "gpt-4"