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

483 lines
19 KiB
Python
Raw Permalink 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_uses_default_template(self):
"""@team 无指定专家时使用默认 dev_team 模板"""
router = ExpertTeamRouter()
result = router.resolve("@team 分析数据")
assert result.matched is True
assert result.team_mode is True
# 默认 dev_team 模板包含 5 个成员
assert result.specified_experts == [
"tech_lead",
"frontend_engineer",
"backend_engineer",
"qa_engineer",
"code_reviewer",
]
assert result.auto_compose is False
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 False
assert len(result.specified_experts) == 5
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
# ── ExpertTeamRouter 团队模板展开测试 (U7) ─────────────────
class TestExpertTeamRouterTemplateExpansion:
"""ExpertTeamRouter 团队模板展开测试 — @team:dev_team 自动展开为成员列表"""
def test_team_template_expands_to_members(self):
"""@team:dev_team 展开为 dev_team 模板的 bound_skills 成员列表"""
registry = ExpertTemplateRegistry()
# 注册 dev_team 模板bound_skills 存储成员列表)
registry.register(
_make_template(
"dev_team",
persona="编程团队",
bound_skills=["tech_lead", "frontend_engineer", "backend_engineer"],
)
)
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team:dev_team 实现用户登录功能")
assert result.matched is True
assert result.team_mode is True
assert result.specified_experts == ["tech_lead", "frontend_engineer", "backend_engineer"]
assert result.auto_compose is False
assert result.match_method == "explicit_team"
def test_team_template_respects_max_experts_limit(self):
"""团队模板展开时遵守 MAX_EXPERTS 上限"""
registry = ExpertTemplateRegistry()
# 注册一个 bound_skills 超过 MAX_EXPERTS 的模板
registry.register(
_make_template(
"dev_team",
persona="编程团队",
bound_skills=[f"expert{i}" for i in range(15)],
)
)
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team:dev_team 任务")
from agentkit.experts.router import MAX_EXPERTS
assert len(result.specified_experts) == MAX_EXPERTS
def test_team_template_not_found_falls_back_to_default_members(self):
"""@team:unknown_template 时未知模板名作为普通专家处理"""
registry = ExpertTemplateRegistry()
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team:unknown_template 任务")
# 未知模板名(无 bound_skills→ 作为普通专家名处理
assert result.specified_experts == ["unknown_template"]
def test_no_experts_uses_default_dev_team_template(self):
"""@team 无指定专家时从 dev_team 模板加载成员"""
registry = ExpertTemplateRegistry()
registry.register(
_make_template(
"dev_team",
persona="编程团队",
bound_skills=["tech_lead", "frontend_engineer", "backend_engineer"],
)
)
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team 实现功能")
assert result.specified_experts == ["tech_lead", "frontend_engineer", "backend_engineer"]
assert result.auto_compose is False
def test_no_experts_no_dev_team_template_uses_hardcoded_fallback(self):
"""无 dev_team 模板时使用硬编码 fallback 列表"""
registry = ExpertTemplateRegistry()
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team 任务")
assert result.specified_experts == [
"tech_lead",
"frontend_engineer",
"backend_engineer",
"qa_engineer",
"code_reviewer",
]
def test_team_template_with_empty_bound_skills_treated_as_expert_name(self):
"""bound_skills 为空的模板名作为普通专家处理"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("empty_team", persona="空团队", bound_skills=[]))
router = ExpertTeamRouter(template_registry=registry)
result = router.resolve("@team:empty_team 任务")
# bound_skills 为空 → 不展开,作为普通专家名
assert result.specified_experts == ["empty_team"]
def test_default_template_class_attribute(self):
"""DEFAULT_TEMPLATE 类属性为 'dev_team'"""
from agentkit.experts.router import ExpertTeamRouter as Router
assert Router.DEFAULT_TEMPLATE == "dev_team"
def test_load_default_template_members_with_template(self):
"""_load_default_template_members 从 dev_team 模板加载"""
registry = ExpertTemplateRegistry()
registry.register(
_make_template(
"dev_team",
persona="编程团队",
bound_skills=["a", "b", "c"],
)
)
router = ExpertTeamRouter(template_registry=registry)
members = router._load_default_template_members()
assert members == ["a", "b", "c"]
def test_load_default_template_members_without_template(self):
"""_load_default_template_members 无模板时返回硬编码列表"""
registry = ExpertTemplateRegistry()
router = ExpertTeamRouter(template_registry=registry)
members = router._load_default_template_members()
assert members == [
"tech_lead",
"frontend_engineer",
"backend_engineer",
"qa_engineer",
"code_reviewer",
]