148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""联网搜索客户端与 LLM 工具闭环的无外部依赖测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from aiohttp import web
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from app.config import AppConfig, SearchConfig # noqa: E402
|
|
from app.engine import ConversationEngine, EventKind # noqa: E402
|
|
from app.identity import ManualIdentityProvider # noqa: E402
|
|
from app.llm.base import Chunk, ChunkKind # noqa: E402
|
|
from app.search import SearchResult, SearXNGSearchClient # noqa: E402
|
|
from app.voice.tts import text_for_speech # noqa: E402
|
|
|
|
|
|
class FakeLLM:
|
|
name = "fake"
|
|
last_backend = "fake"
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def stream(self, messages, *, tools=None, max_tokens=None):
|
|
self.calls.append((list(messages), tools))
|
|
if len(self.calls) == 1:
|
|
# 模拟 OpenAI SSE 中参数逐块累计后的两个中间状态。
|
|
yield Chunk(
|
|
kind=ChunkKind.TOOL_CALL, tool_index=0, tool_id="call_search",
|
|
tool_name="web_search", tool_arguments='{"query":"Deep',
|
|
)
|
|
yield Chunk(
|
|
kind=ChunkKind.TOOL_CALL, tool_index=0, tool_id="call_search",
|
|
tool_name="web_search", tool_arguments='{"query":"DeepSeek 最新"}',
|
|
)
|
|
yield Chunk(kind=ChunkKind.DONE, finish_reason="tool_calls")
|
|
else:
|
|
yield Chunk(kind=ChunkKind.CONTENT, text="根据最新资料,测试成功。")
|
|
yield Chunk(
|
|
kind=ChunkKind.CONTENT,
|
|
text="\n来源:DeepSeek 文档 https://example.com/deepseek",
|
|
)
|
|
yield Chunk(kind=ChunkKind.DONE, finish_reason="stop")
|
|
|
|
|
|
class FakeSearch(SearXNGSearchClient):
|
|
def __init__(self):
|
|
self.queries = []
|
|
|
|
async def search(self, query):
|
|
self.queries.append(query)
|
|
return [SearchResult("DeepSeek 文档", "https://example.com/deepseek", "最新说明")]
|
|
|
|
|
|
class FakeMemory:
|
|
async def start_session(self, *_args): pass
|
|
async def end_session(self, *_args): pass
|
|
async def retrieve(self, *_args, **_kwargs): return []
|
|
async def add_turn(self, *_args): pass
|
|
|
|
|
|
class FakeTTS:
|
|
sample_rate = 16000
|
|
|
|
def __init__(self):
|
|
self.texts = []
|
|
|
|
def synthesize(self, text):
|
|
self.texts.append(text)
|
|
return np.ones(160, dtype=np.float32) * 0.1
|
|
|
|
|
|
async def test_searxng_client() -> None:
|
|
async def search(request):
|
|
assert request.query["format"] == "json"
|
|
assert request.query["q"] == "测试 搜索"
|
|
return web.json_response({"results": [
|
|
{"title": " 结果 一 ", "url": "https://example.com/a",
|
|
"content": " 摘要 内容 ", "engines": ["duckduckgo"]},
|
|
{"title": "坏链接", "url": "javascript:alert(1)"},
|
|
]})
|
|
|
|
app = web.Application()
|
|
app.router.add_get("/search", search)
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
try:
|
|
client = SearXNGSearchClient(SearchConfig(
|
|
enabled=True, base_url=f"http://127.0.0.1:{port}",
|
|
timeout_s=2, max_results=5,
|
|
))
|
|
results = await client.search(" 测试 搜索 ")
|
|
assert len(results) == 1
|
|
assert results[0].title == "结果 一"
|
|
assert results[0].snippet == "摘要 内容"
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
async def test_engine_tool_loop() -> None:
|
|
cfg = AppConfig()
|
|
cfg.search.enabled = True
|
|
cfg.memory.auto_extract_enabled = False
|
|
llm, search, tts = FakeLLM(), FakeSearch(), FakeTTS()
|
|
engine = ConversationEngine(
|
|
cfg, llm, FakeMemory(), ManualIdentityProvider(), tts, search=search,
|
|
)
|
|
await engine.start_session("search_test", user_id="u_test")
|
|
|
|
events = [event async for event in engine.respond_to_text("DeepSeek 最近更新了什么?")]
|
|
reply = "".join(e.text for e in events if e.kind == EventKind.RESPONSE_DELTA)
|
|
calls = [e.tool_call for e in events if e.kind == EventKind.TOOL_CALL]
|
|
metrics = [e.metrics for e in events if e.kind == EventKind.METRICS][-1]
|
|
|
|
assert search.queries == ["DeepSeek 最新"]
|
|
assert len(calls) == 1 and calls[0].id == "call_search"
|
|
assert len(llm.calls) == 2
|
|
second_messages = llm.calls[1][0]
|
|
assert second_messages[-2]["role"] == "assistant"
|
|
assert second_messages[-1]["role"] == "tool"
|
|
assert "https://example.com/deepseek" in second_messages[-1]["content"]
|
|
assert "https://example.com/deepseek" in reply
|
|
assert metrics.searches == 1
|
|
assert tts.texts == ["根据最新资料,测试成功。"]
|
|
|
|
|
|
async def main() -> int:
|
|
await test_searxng_client()
|
|
await test_engine_tool_loop()
|
|
assert text_for_speech("回答。\n来源:文档 https://example.com") == "回答。"
|
|
assert text_for_speech("查看[官方文档](https://example.com)。") == "查看官方文档。"
|
|
print("联网搜索回归测试:12/12 PASS")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(main()))
|