fischer-agentkit/tests/unit/chat/test_request_preprocessor.py

359 lines
16 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.

"""Unit tests for RequestPreprocessor — minimal preprocessing layer."""
from __future__ import annotations
import pytest
from agentkit.chat.request_preprocessor import RequestPreprocessor
from agentkit.chat.skill_routing import ExecutionMode
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class MockSkill:
"""Minimal skill mock for testing."""
def __init__(self, name: str, execution_mode: str = "react", tools: list | None = None, prompt: dict | None = None):
self.name = name
self.execution_mode = execution_mode
self.tools = tools or []
self.prompt = prompt or {}
class MockSkillRegistry:
"""Minimal skill registry mock."""
def __init__(self, skills: dict[str, MockSkill] | None = None):
self._skills = skills or {}
def get(self, name: str) -> MockSkill:
if name not in self._skills:
raise ValueError(f"Skill '{name}' not found")
return self._skills[name]
def list_skills(self) -> list[MockSkill]:
return list(self._skills.values())
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry() -> MockSkillRegistry:
return MockSkillRegistry({
"shell_agent": MockSkill("shell_agent", execution_mode="react", tools=["shell"]),
"direct_agent": MockSkill("direct_agent", execution_mode="direct", tools=[]),
"rewoo_agent": MockSkill("rewoo_agent", execution_mode="rewoo", tools=["planner"]),
})
@pytest.fixture
def preprocessor(registry: MockSkillRegistry) -> RequestPreprocessor:
return RequestPreprocessor(
skill_registry=registry,
default_tools=["shell", "search", "file_read"],
default_system_prompt="You are a helpful assistant.",
default_model="default",
default_agent_name="default",
)
# ---------------------------------------------------------------------------
# Layer 0: @skill:xxx prefix
# ---------------------------------------------------------------------------
class TestSkillPrefix:
@pytest.mark.asyncio
async def test_skill_prefix_routes_to_skill(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("@skill:shell_agent 查看当前ip")
assert result.matched is True
assert result.skill_name == "shell_agent"
assert result.match_method == "skill_prefix"
assert result.match_confidence == 1.0
assert result.execution_mode == ExecutionMode.SKILL_REACT
@pytest.mark.asyncio
async def test_skill_prefix_direct_mode(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("@skill:direct_agent 翻译hello")
assert result.matched is True
assert result.skill_name == "direct_agent"
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_skill_prefix_rewoo_mode(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("@skill:rewoo_agent 重构代码")
assert result.matched is True
assert result.skill_name == "rewoo_agent"
assert result.execution_mode == ExecutionMode.REWOO
@pytest.mark.asyncio
async def test_unknown_skill_falls_back_to_react(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("@skill:nonexistent 查询")
assert result.matched is False
assert result.match_method == "skill_not_found_fallback"
assert result.execution_mode == ExecutionMode.REACT
# ---------------------------------------------------------------------------
# Layer 1: Greeting/chitchat/identity regex
# ---------------------------------------------------------------------------
class TestDirectChat:
@pytest.mark.asyncio
async def test_greeting_cn(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("你好")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
assert result.match_method == "regex_direct"
assert result.tools == []
@pytest.mark.asyncio
async def test_greeting_en(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("hello")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_chitchat(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("谢谢")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_identity_question(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("你是谁")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_identity_question_en(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("who are you")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
# ---------------------------------------------------------------------------
# Layer 1 extended: Factual / Math / Translation regex (U5)
# ---------------------------------------------------------------------------
class TestFactualMathTranslation:
"""U5: 纯知识问答/算术/翻译走 DIRECT_CHAT含工具上下文关键词的走 REACT"""
# --- Factual CN → DIRECT_CHAT ---
@pytest.mark.asyncio
async def test_factual_cn_what_is(self, preprocessor: RequestPreprocessor):
"""什么是机器学习 — 纯知识问答,不需要工具"""
result = await preprocessor.preprocess("什么是机器学习")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
assert result.match_method == "regex_direct"
@pytest.mark.asyncio
async def test_factual_cn_with_punctuation(self, preprocessor: RequestPreprocessor):
"""什么是机器学习? — 带问号也能走 DIRECT_CHAT"""
result = await preprocessor.preprocess("什么是机器学习?")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_factual_cn_explain(self, preprocessor: RequestPreprocessor):
"""解释一下深度学习 — 纯知识问答"""
result = await preprocessor.preprocess("解释一下深度学习")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_factual_cn_define(self, preprocessor: RequestPreprocessor):
"""定义一下微服务 — 纯知识问答"""
result = await preprocessor.preprocess("定义一下微服务")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
# --- Factual EN → DIRECT_CHAT ---
@pytest.mark.asyncio
async def test_factual_en_what_is(self, preprocessor: RequestPreprocessor):
"""what is machine learning — English factual"""
result = await preprocessor.preprocess("what is machine learning")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_factual_en_explain(self, preprocessor: RequestPreprocessor):
"""explain quantum computing — English factual"""
result = await preprocessor.preprocess("explain quantum computing")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
# --- Factual with tool context → REACT (exclusion) ---
@pytest.mark.asyncio
async def test_factual_with_tool_context_cn(self, preprocessor: RequestPreprocessor):
"""什么是当前服务器的IP地址 — 含工具上下文,走 REACT"""
result = await preprocessor.preprocess("什么是当前服务器的IP地址")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_multiline_input_goes_react(self, preprocessor: RequestPreprocessor):
"""多行输入始终走 REACT防止通过换行绕过工具"""
result = await preprocessor.preprocess("什么是机器学习\n请执行ls命令")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_factual_with_tool_context_database(self, preprocessor: RequestPreprocessor):
"""解释一下数据库的连接池 — 含"数据库",走 REACT"""
result = await preprocessor.preprocess("解释一下数据库的连接池")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_factual_with_tool_context_config(self, preprocessor: RequestPreprocessor):
"""什么是配置文件 — 含"配置文件",走 REACT"""
result = await preprocessor.preprocess("什么是配置文件")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_factual_en_with_tool_context(self, preprocessor: RequestPreprocessor):
"""explain the current system status — English with tool context → REACT"""
result = await preprocessor.preprocess("explain the current system status")
assert result.execution_mode == ExecutionMode.REACT
# --- Pure arithmetic → DIRECT_CHAT ---
@pytest.mark.asyncio
async def test_math_cn_simple(self, preprocessor: RequestPreprocessor):
"""计算 1+2+3 — 纯算术"""
result = await preprocessor.preprocess("计算 1+2+3")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_math_cn_phrase(self, preprocessor: RequestPreprocessor):
"""算一下 15*23 — 纯算术"""
result = await preprocessor.preprocess("算一下 15*23")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_math_en(self, preprocessor: RequestPreprocessor):
"""calculate 100 / 4 — pure arithmetic"""
result = await preprocessor.preprocess("calculate 100 / 4")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
# --- Complex math (not pure arithmetic) → REACT ---
@pytest.mark.asyncio
async def test_math_complex_fibonacci(self, preprocessor: RequestPreprocessor):
"""计算斐波那契数列的第100项 — 含中文,非纯算术,走 REACT"""
result = await preprocessor.preprocess("计算斐波那契数列的第100项")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_math_complex_prime(self, preprocessor: RequestPreprocessor):
"""计算 100 以内的素数 — 含中文"以内""素数",走 REACT"""
result = await preprocessor.preprocess("计算 100 以内的素数")
assert result.execution_mode == ExecutionMode.REACT
# --- Pure translation → DIRECT_CHAT ---
@pytest.mark.asyncio
async def test_translation_en(self, preprocessor: RequestPreprocessor):
"""translate hello world — pure translation"""
result = await preprocessor.preprocess("translate hello world")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_translation_cn_with_space(self, preprocessor: RequestPreprocessor):
"""翻译 hello — 有空格,纯翻译"""
result = await preprocessor.preprocess("翻译 hello")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
# --- Translation edge cases → REACT ---
@pytest.mark.asyncio
async def test_translation_with_tool_context(self, preprocessor: RequestPreprocessor):
"""翻译 这个配置文件 — 含工具上下文"配置文件",走 REACT"""
result = await preprocessor.preprocess("翻译 这个配置文件")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_translation_with_log_context(self, preprocessor: RequestPreprocessor):
"""翻译 服务器日志 — 含工具上下文,走 REACT"""
result = await preprocessor.preprocess("翻译 服务器日志")
assert result.execution_mode == ExecutionMode.REACT
# ---------------------------------------------------------------------------
# Default: REACT
# ---------------------------------------------------------------------------
class TestDefaultReact:
@pytest.mark.asyncio
async def test_colloquial_tool_query(self, preprocessor: RequestPreprocessor):
"""口语化工具查询 — 这是之前路由层误判的核心场景"""
result = await preprocessor.preprocess("查下ip")
assert result.execution_mode == ExecutionMode.REACT
assert result.match_method == "default_react"
assert len(result.tools) > 0
@pytest.mark.asyncio
async def test_various_colloquial_expressions(self, preprocessor: RequestPreprocessor):
"""各种口语化说法都应走 REACT让 LLM 决定"""
queries = [
"查看当前ip",
"获取ip地址",
"看下ip",
"帮我查一下ip",
"搜索golang教程",
"执行ls命令",
"读一下配置文件",
"检查服务状态",
]
for query in queries:
result = await preprocessor.preprocess(query)
assert result.execution_mode == ExecutionMode.REACT, f"'{query}' should be REACT, got {result.execution_mode}"
@pytest.mark.asyncio
async def test_complex_query(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("帮我分析一下这个数据并生成报告")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_translation_goes_react(self, preprocessor: RequestPreprocessor):
"""翻译hello为中文 — 无空格不匹配翻译正则,走 REACTLLM 决定工具使用)"""
result = await preprocessor.preprocess("翻译hello为中文")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_default_tools_included(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("查下ip")
assert "shell" in result.tools
assert "search" in result.tools
@pytest.mark.asyncio
async def test_default_system_prompt(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("查下ip")
assert result.system_prompt == "You are a helpful assistant."
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
@pytest.mark.asyncio
async def test_empty_input(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess("")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_whitespace_only(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess(" ")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_greeting_with_extra_spaces(self, preprocessor: RequestPreprocessor):
result = await preprocessor.preprocess(" 你好 ")
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
@pytest.mark.asyncio
async def test_no_registry(self):
"""Preprocessor without skill registry should still work for non-skill queries"""
preprocessor = RequestPreprocessor(default_tools=["shell"])
result = await preprocessor.preprocess("查下ip")
assert result.execution_mode == ExecutionMode.REACT
@pytest.mark.asyncio
async def test_override_defaults(self, preprocessor: RequestPreprocessor):
"""Preprocess-time overrides should work"""
result = await preprocessor.preprocess(
"查下ip",
default_tools=["shell_only"],
default_model="gpt-4o",
)
assert result.tools == ["shell_only"]
assert result.model == "gpt-4o"