修复网页对话并接入 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()))
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user