fischer-agentkit/tests/unit/router/test_intent.py

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"