201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
"""Unit tests for IntentRouter multi-candidate keyword scoring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from agentkit.router.intent import IntentRouter
|
|
from agentkit.skills.base import Skill, SkillConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_skill(name: str, keywords: list[str], description: str = "") -> Skill:
|
|
"""Create a Skill with the given name and intent keywords."""
|
|
config = SkillConfig(
|
|
name=name,
|
|
agent_type="skill",
|
|
description=description or f"Skill {name}",
|
|
task_mode="llm_generate",
|
|
prompt={"identity": f"You are {name}"},
|
|
intent={"keywords": keywords, "description": description or f"Skill {name}"},
|
|
)
|
|
return Skill(config=config)
|
|
|
|
|
|
def _make_skills(*specs: tuple[str, list[str]]) -> list[Skill]:
|
|
"""Create multiple skills from (name, keywords) tuples."""
|
|
return [_make_skill(name, kws) for name, kws in specs]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Single-candidate match (backward compatible)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSingleCandidateMatch:
|
|
"""When only one skill matches, behavior is identical to old first-match."""
|
|
|
|
def test_single_skill_matches(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["规划", "执行"]), ("skill_b", ["搜索", "查询"]))
|
|
result = router._match_keywords({"content": "帮我规划一个项目"}, skills)
|
|
assert result is not None
|
|
assert result.matched_skill == "skill_a"
|
|
assert result.method == "keyword"
|
|
|
|
def test_single_keyword_match_confidence(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["规划"]))
|
|
result = router._match_keywords({"content": "规划任务"}, skills)
|
|
assert result is not None
|
|
# 1 keyword matched → confidence = min(1.0, 0.5 + 0.1 * 1) = 0.6
|
|
assert result.confidence == 0.6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Multi-candidate scoring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMultiCandidateScoring:
|
|
"""When multiple skills match, the best-scored one wins."""
|
|
|
|
def test_longer_keyword_wins(self) -> None:
|
|
"""'调研报告' (4 chars) beats '报告' (2 chars) on same input."""
|
|
router = IntentRouter()
|
|
skills = _make_skills(
|
|
("plan_exec", ["规划", "报告"]),
|
|
("goal_driven", ["调研报告", "生成"]),
|
|
)
|
|
result = router._match_keywords({"content": "规划一个调研报告"}, skills)
|
|
assert result is not None
|
|
# plan_exec: "规划"(2) + "报告"(2) = 4; goal_driven: "调研报告"(4) = 4
|
|
# Same score → alphabetical: goal_driven < plan_exec
|
|
assert result.matched_skill == "goal_driven"
|
|
|
|
def test_more_keywords_wins(self) -> None:
|
|
"""Skill matching 3 keywords beats skill matching 1 keyword."""
|
|
router = IntentRouter()
|
|
skills = _make_skills(
|
|
("skill_a", ["分析"]),
|
|
("skill_b", ["分析", "市场", "趋势"]),
|
|
)
|
|
result = router._match_keywords({"content": "分析市场趋势"}, skills)
|
|
assert result is not None
|
|
# skill_a: "分析"(2) = 2; skill_b: "分析"(2)+"市场"(2)+"趋势"(2) = 6
|
|
assert result.matched_skill == "skill_b"
|
|
|
|
def test_same_score_alphabetical(self) -> None:
|
|
"""When scores are equal, alphabetical name order breaks the tie."""
|
|
router = IntentRouter()
|
|
skills = _make_skills(
|
|
("zebra_skill", ["分析"]),
|
|
("alpha_skill", ["分析"]),
|
|
)
|
|
result = router._match_keywords({"content": "分析数据"}, skills)
|
|
assert result is not None
|
|
assert result.matched_skill == "alpha_skill"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: No match
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoMatch:
|
|
def test_no_keyword_match(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["搜索"]), ("skill_b", ["查询"]))
|
|
result = router._match_keywords({"content": "你好"}, skills)
|
|
assert result is None
|
|
|
|
def test_empty_keywords_list(self) -> None:
|
|
"""Skill with empty keywords list does not participate in matching."""
|
|
router = IntentRouter()
|
|
skills = [_make_skill("empty_kw", [])]
|
|
result = router._match_keywords({"content": "anything"}, skills)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Case insensitivity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCaseInsensitivity:
|
|
def test_english_keyword_case_insensitive(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["Search"]))
|
|
result = router._match_keywords({"content": "please search for this"}, skills)
|
|
assert result is not None
|
|
assert result.matched_skill == "skill_a"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Substring matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubstringMatch:
|
|
def test_chinese_substring_match(self) -> None:
|
|
"""Chinese keyword '报告' should match input containing '报告'."""
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["报告"]))
|
|
result = router._match_keywords({"content": "生成一份报告"}, skills)
|
|
assert result is not None
|
|
assert result.matched_skill == "skill_a"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Confidence calculation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfidenceCalculation:
|
|
def test_one_keyword_confidence(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["分析"]))
|
|
result = router._match_keywords({"content": "分析数据"}, skills)
|
|
assert result is not None
|
|
assert result.confidence == 0.6 # 0.5 + 0.1 * 1
|
|
|
|
def test_three_keywords_confidence(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["分析", "市场", "趋势"]))
|
|
result = router._match_keywords({"content": "分析市场趋势"}, skills)
|
|
assert result is not None
|
|
assert result.confidence == 0.8 # 0.5 + 0.1 * 3
|
|
|
|
def test_confidence_capped_at_one(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["a", "b", "c", "d", "e", "f"]))
|
|
result = router._match_keywords({"content": "a b c d e f"}, skills)
|
|
assert result is not None
|
|
assert result.confidence == 1.0 # min(1.0, 0.5 + 0.1 * 6 = 1.1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_input_text(self) -> None:
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["分析"]))
|
|
result = router._match_keywords({"content": ""}, skills)
|
|
assert result is None
|
|
|
|
def test_nested_input_data(self) -> None:
|
|
"""_extract_string_values should handle nested dicts/lists."""
|
|
router = IntentRouter()
|
|
skills = _make_skills(("skill_a", ["分析"]))
|
|
result = router._match_keywords(
|
|
{"message": {"text": "分析数据", "meta": {"role": "user"}}},
|
|
skills,
|
|
)
|
|
assert result is not None
|
|
assert result.matched_skill == "skill_a"
|