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