fischer-agentkit/tests/unit/test_intent_router.py

355 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 LLMGatewaychat 返回指定 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"