修复网页对话并接入 SearXNG 搜索

This commit is contained in:
ylhan
2026-09-16 11:17:41 +08:00
parent c3d2c7f3a8
commit 719fc13e48
13 changed files with 840 additions and 52 deletions
+147
View File
@@ -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()))
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""真实 DeepSeek → SearXNG → DeepSeek 联网闭环验收。
需要服务器密钥与已启动的 SearXNG,不属于日常离线回归测试。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from app.config import AppConfig # noqa: E402
from app.llm import create_provider # noqa: E402
from app.search import ( # noqa: E402
WEB_SEARCH_PROMPT,
WEB_SEARCH_TOOL,
SearXNGSearchClient,
)
async def main() -> int:
cfg = AppConfig.load()
if not cfg.search.enabled:
raise RuntimeError("请先设置 SEARCH_ENABLED=true")
llm = create_provider(cfg.llm)
search = SearXNGSearchClient(cfg.search)
messages = [
{"role": "system", "content": WEB_SEARCH_PROMPT},
{"role": "user", "content": "请搜索 DeepSeek 最近有什么消息,用一句话概括。"},
]
try:
first = await llm.complete(messages, tools=[WEB_SEARCH_TOOL])
if not first.tool_calls:
raise AssertionError(f"模型没有调用搜索工具,直接回答:{first.content!r}")
call = first.tool_calls[0]
query = call.parsed_arguments().get("query")
if not isinstance(query, str) or not query.strip():
raise AssertionError(f"搜索参数无效:{call.arguments!r}")
results = await search.search(query)
if not results:
raise AssertionError(f"SearXNG 对 {query!r} 没有返回结果")
messages.extend([
{
"role": "assistant",
"content": first.content or None,
"tool_calls": [{
"id": call.id,
"type": "function",
"function": {"name": call.name, "arguments": call.arguments},
}],
},
{
"role": "tool",
"tool_call_id": call.id,
"content": search.for_model(query, results),
},
])
final = await llm.complete(messages, max_tokens=250)
if not final.content.strip():
raise AssertionError("搜索回灌后模型没有生成回答")
print(f"工具调用: {call.name}({query!r})")
print(f"搜索结果: {len(results)} 条;首条={results[0].title!r}")
print(f"最终回答: {final.content.strip()}")
print("真实联网闭环:PASS")
return 0
finally:
await llm.aclose()
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+15 -2
View File
@@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT))
from client.test_client import FileAudioTrack, ReplyRecorder
from services.bot_server import SimpleVAD
from services.bot_server import SimpleVAD, extract_user_text
def _write_test_wav(path: Path) -> None:
@@ -66,11 +66,24 @@ def test_reply_requires_non_silent_audio() -> None:
assert recorder.has_speech
def test_extract_rtvi_user_text() -> None:
assert extract_user_text([
{"role": "assistant", "content": "旧回复"},
{"role": "user", "content": " 文字问题 "},
]) == "文字问题"
assert extract_user_text([{
"role": "user",
"content": [{"type": "text", "text": "第一段"}, {"text": "第二段"}],
}]) == "第一段 第二段"
assert extract_user_text([{"role": "assistant", "content": "没有用户消息"}]) == ""
async def main() -> int:
await test_file_track_clock()
test_vad_tail_silence()
test_reply_requires_non_silent_audio()
print("传输适配层回归测试:3/3 PASS")
test_extract_rtvi_user_text()
print("传输适配层回归测试:4/4 PASS")
return 0