#!/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()))