修复网页对话并接入 SearXNG 搜索
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
#!/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()))
|
||||
Reference in New Issue
Block a user