fischer-agentkit/tests/unit/experts/test_router.py

345 lines
13 KiB
Python
Raw 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.

"""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):
"""有模板但内容不匹配时仍返回 Trueauto-compose 可组建团队)"""
registry = _make_registry_with_templates()
router = ExpertTeamRouter(template_registry=registry)
# 内容与任何模板名/描述都不匹配,但有模板存在 → auto-compose 可用
assert router.can_handle("完全无关的内容 xyz123") is True