345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""ExpertTeamRouter 单元测试"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from agentkit.experts.config import ExpertConfig, ExpertTemplate
|
||
from agentkit.experts.registry import ExpertTemplateRegistry
|
||
from agentkit.experts.router import (
|
||
ExpertTeamRouter,
|
||
ExpertTeamRoutingResult,
|
||
TEAM_PREFIX_PATTERN,
|
||
)
|
||
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────
|
||
|
||
|
||
def _make_template(
|
||
name: str = "test_template",
|
||
persona: str = "测试专家",
|
||
bound_skills: list[str] | None = None,
|
||
) -> ExpertTemplate:
|
||
"""创建测试用 ExpertTemplate 实例"""
|
||
config = ExpertConfig(
|
||
name=name,
|
||
agent_type="expert",
|
||
persona=persona,
|
||
thinking_style="analytical",
|
||
bound_skills=bound_skills or [],
|
||
task_mode="llm_generate",
|
||
prompt={"identity": persona},
|
||
)
|
||
return ExpertTemplate(
|
||
name=name,
|
||
config=config,
|
||
is_builtin=True,
|
||
description=f"{name} 模板",
|
||
)
|
||
|
||
|
||
def _make_registry_with_templates() -> ExpertTemplateRegistry:
|
||
"""创建包含预注册模板的注册中心"""
|
||
registry = ExpertTemplateRegistry()
|
||
registry.register(_make_template("analyst", persona="数据分析师", bound_skills=["data_query"]))
|
||
registry.register(_make_template("strategist", persona="策略专家", bound_skills=["planning"]))
|
||
registry.register(_make_template("reviewer", persona="代码审查员", bound_skills=["code_review"]))
|
||
return registry
|
||
|
||
|
||
# ── TEAM_PREFIX_PATTERN 正则测试 ──────────────────────────
|
||
|
||
|
||
class TestTeamPrefixPattern:
|
||
"""TEAM_PREFIX_PATTERN 正则匹配测试"""
|
||
|
||
def test_match_team_only(self):
|
||
"""@team 前缀匹配"""
|
||
match = TEAM_PREFIX_PATTERN.match("@team 分析这个数据")
|
||
assert match is not None
|
||
assert match.group(1) is None
|
||
assert match.group(2) == "分析这个数据"
|
||
|
||
def test_match_team_with_experts(self):
|
||
"""@team:expert1,expert2 前缀匹配"""
|
||
match = TEAM_PREFIX_PATTERN.match("@team:analyst,strategist 分析这个数据")
|
||
assert match is not None
|
||
assert match.group(1) == "analyst,strategist"
|
||
assert match.group(2) == "分析这个数据"
|
||
|
||
def test_match_team_single_expert(self):
|
||
"""@team:expert 前缀匹配"""
|
||
match = TEAM_PREFIX_PATTERN.match("@team:analyst 分析数据")
|
||
assert match is not None
|
||
assert match.group(1) == "analyst"
|
||
assert match.group(2) == "分析数据"
|
||
|
||
def test_match_team_no_task(self):
|
||
"""@team 无后续任务内容"""
|
||
match = TEAM_PREFIX_PATTERN.match("@team")
|
||
assert match is not None
|
||
assert match.group(1) is None
|
||
assert match.group(2) == ""
|
||
|
||
def test_no_match_without_prefix(self):
|
||
"""无 @team 前缀不匹配"""
|
||
match = TEAM_PREFIX_PATTERN.match("分析这个数据")
|
||
assert match is None
|
||
|
||
def test_no_match_team_in_middle(self):
|
||
"""@team 在中间不匹配(必须开头)"""
|
||
match = TEAM_PREFIX_PATTERN.match("请 @team 分析数据")
|
||
assert match is None
|
||
|
||
def test_match_team_with_leading_whitespace(self):
|
||
"""@team 前有空白字符时正则不匹配(由 resolve() 中 strip() 处理)"""
|
||
match = TEAM_PREFIX_PATTERN.match(" @team:analyst 任务内容")
|
||
# 正则使用 ^ 锚定,前导空白不匹配
|
||
assert match is None
|
||
|
||
|
||
# ── ExpertTeamRoutingResult 默认值测试 ────────────────────
|
||
|
||
|
||
class TestExpertTeamRoutingResult:
|
||
"""ExpertTeamRoutingResult 数据类测试"""
|
||
|
||
def test_default_values(self):
|
||
"""默认值验证"""
|
||
result = ExpertTeamRoutingResult()
|
||
assert result.matched is False
|
||
assert result.team_mode is False
|
||
assert result.specified_experts == []
|
||
assert result.task_content == ""
|
||
assert result.auto_compose is False
|
||
assert result.match_method == ""
|
||
|
||
|
||
# ── ExpertTeamRouter.resolve 测试 ──────────────────────────
|
||
|
||
|
||
class TestExpertTeamRouterResolve:
|
||
"""ExpertTeamRouter.resolve 方法测试"""
|
||
|
||
def test_team_prefix_triggers_team_mode(self):
|
||
"""@team 前缀触发团队模式"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team 分析这个数据")
|
||
assert result.matched is True
|
||
assert result.team_mode is True
|
||
assert result.match_method == "explicit_team"
|
||
assert result.task_content == "分析这个数据"
|
||
|
||
def test_team_with_experts_specifies_members(self):
|
||
"""@team:analyst,strategist 指定专家成员"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team:analyst,strategist 分析数据")
|
||
assert result.matched is True
|
||
assert result.team_mode is True
|
||
assert result.specified_experts == ["analyst", "strategist"]
|
||
assert result.auto_compose is False
|
||
assert result.match_method == "explicit_team"
|
||
|
||
def test_team_no_experts_auto_compose(self):
|
||
"""@team 无指定专家时 auto_compose=True"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team 分析数据")
|
||
assert result.matched is True
|
||
assert result.team_mode is True
|
||
assert result.specified_experts == []
|
||
assert result.auto_compose is True
|
||
|
||
def test_team_with_expert_extracts_task(self):
|
||
"""@team:analyst 正确提取任务内容"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team:analyst 请分析这份报告")
|
||
assert result.matched is True
|
||
assert result.specified_experts == ["analyst"]
|
||
assert result.task_content == "请分析这份报告"
|
||
|
||
def test_no_team_prefix_no_team_mode(self):
|
||
"""无 @team 前缀不触发团队模式"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("普通问题")
|
||
assert result.matched is False
|
||
assert result.team_mode is False
|
||
assert result.task_content == "普通问题"
|
||
assert result.match_method == ""
|
||
|
||
def test_nonexistent_expert_still_included(self):
|
||
"""指定不存在的专家名仍包含在列表中"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team:analyst,nonexistent 任务")
|
||
assert result.specified_experts == ["analyst", "nonexistent"]
|
||
|
||
def test_team_with_empty_task_uses_full_content(self):
|
||
"""@team 无任务内容时 task_content 使用原始内容"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve("@team")
|
||
assert result.task_content == "@team"
|
||
assert result.auto_compose is True
|
||
|
||
def test_resolve_strips_leading_whitespace(self):
|
||
"""resolve() 对前导空白做 strip()"""
|
||
router = ExpertTeamRouter()
|
||
result = router.resolve(" @team:analyst 任务内容")
|
||
assert result.matched is True
|
||
assert result.team_mode is True
|
||
assert result.specified_experts == ["analyst"]
|
||
assert result.task_content == "任务内容"
|
||
|
||
def test_invalid_expert_names_rejected(self):
|
||
"""无效专家名被过滤掉"""
|
||
router = ExpertTeamRouter()
|
||
# 包含特殊字符的名称应被过滤
|
||
result = router.resolve("@team:analyst,inva!id,bad/name 任务")
|
||
# 仅保留合法名称
|
||
assert "analyst" in result.specified_experts
|
||
assert "inva!id" not in result.specified_experts
|
||
assert "bad/name" not in result.specified_experts
|
||
|
||
def test_max_experts_limit(self):
|
||
"""指定超过 MAX_EXPERTS 数量的专家时截断"""
|
||
router = ExpertTeamRouter()
|
||
# 构造 15 个专家名
|
||
names = ",".join(f"expert{i}" for i in range(15))
|
||
result = router.resolve(f"@team:{names} 任务")
|
||
from agentkit.experts.router import MAX_EXPERTS
|
||
|
||
assert len(result.specified_experts) == MAX_EXPERTS
|
||
|
||
|
||
# ── ExpertTeamRouter.resolve_expert_configs 测试 ───────────
|
||
|
||
|
||
class TestExpertTeamRouterResolveExpertConfigs:
|
||
"""ExpertTeamRouter.resolve_expert_configs 方法测试"""
|
||
|
||
def test_resolve_existing_templates(self):
|
||
"""解析已注册模板返回对应 ExpertConfig"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
configs = router.resolve_expert_configs(["analyst", "strategist"])
|
||
assert len(configs) == 2
|
||
assert configs[0].name == "analyst"
|
||
assert configs[0].persona == "数据分析师"
|
||
assert configs[1].name == "strategist"
|
||
assert configs[1].persona == "策略专家"
|
||
|
||
def test_resolve_nonexistent_creates_dynamic_config(self):
|
||
"""解析不存在的名称创建动态 ExpertConfig"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
configs = router.resolve_expert_configs(["analyst", "unknown_expert"])
|
||
assert len(configs) == 2
|
||
assert configs[0].name == "analyst"
|
||
assert configs[0].persona == "数据分析师"
|
||
# 动态生成的配置
|
||
assert configs[1].name == "unknown_expert"
|
||
assert configs[1].persona == "Expert in unknown_expert"
|
||
assert configs[1].agent_type == "expert"
|
||
assert configs[1].thinking_style == "analytical"
|
||
assert configs[1].bound_skills == []
|
||
assert configs[1].is_lead is False
|
||
assert configs[1].task_mode == "llm_generate"
|
||
|
||
def test_resolve_all_nonexistent(self):
|
||
"""所有名称都不存在时全部动态生成"""
|
||
router = ExpertTeamRouter()
|
||
configs = router.resolve_expert_configs(["role_a", "role_b"])
|
||
assert len(configs) == 2
|
||
assert configs[0].name == "role_a"
|
||
assert configs[0].persona == "Expert in role_a"
|
||
assert configs[1].name == "role_b"
|
||
assert configs[1].persona == "Expert in role_b"
|
||
|
||
def test_resolve_empty_list(self):
|
||
"""空列表返回空结果"""
|
||
router = ExpertTeamRouter()
|
||
configs = router.resolve_expert_configs([])
|
||
assert configs == []
|
||
|
||
def test_resolve_preserves_template_skills(self):
|
||
"""解析已注册模板保留 bound_skills"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
configs = router.resolve_expert_configs(["analyst"])
|
||
assert configs[0].bound_skills == ["data_query"]
|
||
|
||
def test_resolve_first_expert_is_lead(self):
|
||
"""第一个专家被指定为 lead"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
configs = router.resolve_expert_configs(["analyst", "strategist", "reviewer"])
|
||
assert configs[0].is_lead is True
|
||
assert configs[1].is_lead is False
|
||
assert configs[2].is_lead is False
|
||
|
||
def test_resolve_skips_invalid_names(self):
|
||
"""跳过无效的专家名"""
|
||
router = ExpertTeamRouter()
|
||
# 包含无效字符的名称应被跳过
|
||
configs = router.resolve_expert_configs(["valid_name", "inva!id", "also_valid"])
|
||
assert len(configs) == 2
|
||
assert configs[0].name == "valid_name"
|
||
assert configs[1].name == "also_valid"
|
||
|
||
|
||
# ── ExpertTeamRouter 构造测试 ─────────────────────────────
|
||
|
||
|
||
class TestExpertTeamRouterInit:
|
||
"""ExpertTeamRouter 构造函数测试"""
|
||
|
||
def test_default_registry(self):
|
||
"""无参构造创建默认注册中心"""
|
||
router = ExpertTeamRouter()
|
||
assert router._registry is not None
|
||
|
||
def test_custom_registry(self):
|
||
"""传入自定义注册中心"""
|
||
registry = ExpertTemplateRegistry()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
assert router._registry is registry
|
||
|
||
|
||
# ── ExpertTeamRouter.can_handle 测试 ──────────────────────
|
||
|
||
|
||
class TestExpertTeamRouterCanHandle:
|
||
"""ExpertTeamRouter.can_handle 方法测试"""
|
||
|
||
def test_can_handle_with_matching_template_name(self):
|
||
"""模板名出现在内容中时返回 True"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
assert router.can_handle("请 analyst 帮我分析") is True
|
||
|
||
def test_can_handle_with_matching_description(self):
|
||
"""模板描述词出现在内容中时返回 True"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
# 描述为 "analyst 模板" 等,"模板" 长度 > 2 应匹配
|
||
assert router.can_handle("我需要一个模板来参考") is True
|
||
|
||
def test_can_handle_no_templates(self):
|
||
"""无注册模板时返回 False"""
|
||
router = ExpertTeamRouter()
|
||
# 默认注册中心可能为空
|
||
# 强制清空以测试
|
||
router._registry._templates.clear()
|
||
assert router.can_handle("任何内容") is False
|
||
|
||
def test_can_handle_with_templates_no_match(self):
|
||
"""有模板但内容不匹配时仍返回 True(auto-compose 可组建团队)"""
|
||
registry = _make_registry_with_templates()
|
||
router = ExpertTeamRouter(template_registry=registry)
|
||
|
||
# 内容与任何模板名/描述都不匹配,但有模板存在 → auto-compose 可用
|
||
assert router.can_handle("完全无关的内容 xyz123") is True
|