Spaces:
Running
Running
SilverElixir commited on
Commit ·
659da01
1
Parent(s): 0e888a7
Cover external web search with unit and route tests
Browse files- tests/test_bot_search.py +61 -0
- tests/test_lumen_router_config.py +19 -0
- tests/test_lumen_search.py +138 -0
tests/test_bot_search.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_bot_search.py — сквозные тесты внешнего поиска в _handle_message_core.
|
| 3 |
+
|
| 4 |
+
Что защищают: инцидент 11.10.2026 — явная просьба "погугли" при мёртвой квоте
|
| 5 |
+
2.5 шла на модели без поиска и отвечала по памяти. Первый тест ловит подклейку
|
| 6 |
+
сниппетов в промпт, второй — честную пометку, когда и внешний поиск пуст.
|
| 7 |
+
Квота гасится точечно (только поисковые Gemini), fetch мокается на границе I/O.
|
| 8 |
+
|
| 9 |
+
Запуск:
|
| 10 |
+
pytest tests/test_bot_search.py -v
|
| 11 |
+
"""
|
| 12 |
+
import asyncio
|
| 13 |
+
|
| 14 |
+
import bot
|
| 15 |
+
from tests.bot_test_helpers import _run_core_capturing_prompt
|
| 16 |
+
|
| 17 |
+
_DEAD_SEARCH_QUOTA = {("gemini", "gemini-2.5-flash"), ("gemini", "gemini-2.5-flash-lite")}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _kill_search_quota(monkeypatch):
|
| 21 |
+
import lumen_router_config
|
| 22 |
+
monkeypatch.setattr(
|
| 23 |
+
lumen_router_config,
|
| 24 |
+
"_is_quota_exhausted",
|
| 25 |
+
lambda provider, model_id: (provider, model_id) in _DEAD_SEARCH_QUOTA,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_explicit_search_request_gets_snippets_without_live_search(rate_guard_setup, monkeypatch):
|
| 30 |
+
message = rate_guard_setup()
|
| 31 |
+
message.text = "погугли что происходит с ДЦ Яндекса"
|
| 32 |
+
_kill_search_quota(monkeypatch)
|
| 33 |
+
|
| 34 |
+
async def fake_fetch(query, *, timeout_sec=None, max_results=None):
|
| 35 |
+
assert "ДЦ" in query
|
| 36 |
+
return [{"title": "Новость", "url": "https://example.com/n", "snippet": "Свежие данные"}]
|
| 37 |
+
|
| 38 |
+
monkeypatch.setattr("lumen_message_core.fetch_web_snippets", fake_fetch)
|
| 39 |
+
prompt = _run_core_capturing_prompt(message, monkeypatch)
|
| 40 |
+
assert "Web search snippets" in prompt
|
| 41 |
+
assert "https://example.com/n" in prompt
|
| 42 |
+
bot.chat_state.pop(123, None)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_empty_fallback_search_marks_reply_without_live_search(rate_guard_setup, monkeypatch):
|
| 46 |
+
message = rate_guard_setup()
|
| 47 |
+
message.text = "погугли что происходит с ДЦ Яндекса"
|
| 48 |
+
_kill_search_quota(monkeypatch)
|
| 49 |
+
|
| 50 |
+
async def fake_fetch(query, *, timeout_sec=None, max_results=None):
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
|
| 54 |
+
return "ok", False
|
| 55 |
+
|
| 56 |
+
monkeypatch.setattr("lumen_message_core.fetch_web_snippets", fake_fetch)
|
| 57 |
+
monkeypatch.setattr(bot, "_run_route", fake_run_route)
|
| 58 |
+
asyncio.run(bot._handle_message_core(message))
|
| 59 |
+
sent_text = bot._safe_reply.await_args[0][1]
|
| 60 |
+
assert "without live web search" in sent_text
|
| 61 |
+
bot.chat_state.pop(123, None)
|
tests/test_lumen_router_config.py
CHANGED
|
@@ -110,6 +110,25 @@ def test_looks_like_freshness_query_ignores_substrings():
|
|
| 110 |
assert lumen_router_config._looks_like_freshness_query("groundbreaking discovery") is False
|
| 111 |
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
def test_looks_like_heavy_query_detects_english():
|
| 114 |
# Внешний аудит: "write a Python parser" пролетал в light-ветку.
|
| 115 |
assert lumen_router_config._looks_like_heavy_query("write a Python parser for csv") is True
|
|
|
|
| 110 |
assert lumen_router_config._looks_like_freshness_query("groundbreaking discovery") is False
|
| 111 |
|
| 112 |
|
| 113 |
+
def test_looks_like_search_request_detects_explicit_tool_asks():
|
| 114 |
+
# Прод 11.10.2026: "найди с веб поиском" без слов свежести уходило на модели
|
| 115 |
+
# без поиска — явная просьба искать теперь тоже ведёт на свежий маршрут.
|
| 116 |
+
assert lumen_router_config._looks_like_search_request("погугли что с ДЦ Яндекса") is True
|
| 117 |
+
assert lumen_router_config._looks_like_search_request("найди инфу с помощью веб-поиска") is True
|
| 118 |
+
assert lumen_router_config._looks_like_search_request("ищи через поиск, что там с дата-центрами") is True
|
| 119 |
+
assert lumen_router_config._looks_like_search_request("search the web for Yandex data centers") is True
|
| 120 |
+
assert lumen_router_config._looks_like_search_request("поищи в интернете свежие новости") is True
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_looks_like_search_request_ignores_code_searches():
|
| 124 |
+
# Голые "найди/ищи" без упоминания поиска — про код, а не про веб: иначе
|
| 125 |
+
# "найди баг" гнал бы тяжёлый разбор кода на поисковый маршрут.
|
| 126 |
+
assert lumen_router_config._looks_like_search_request("найди баг в скрипте") is False
|
| 127 |
+
assert lumen_router_config._looks_like_search_request("найди ошибку") is False
|
| 128 |
+
assert lumen_router_config._looks_like_search_request("привет") is False
|
| 129 |
+
assert lumen_router_config._looks_like_search_request("") is False
|
| 130 |
+
|
| 131 |
+
|
| 132 |
def test_looks_like_heavy_query_detects_english():
|
| 133 |
# Внешний аудит: "write a Python parser" пролетал в light-ветку.
|
| 134 |
assert lumen_router_config._looks_like_heavy_query("write a Python parser for csv") is True
|
tests/test_lumen_search.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_lumen_search.py — юнит-тесты на lumen_search.py: парсер lite-выдачи DuckDuckGo,
|
| 3 |
+
блок сниппетов в промпт, детект живого поиска в маршруте, килл-свитч и фолбэк
|
| 4 |
+
fetch на [] без сети.
|
| 5 |
+
|
| 6 |
+
Что защищают: инцидент 11.10.2026 — свежий запрос при мёртвой квоте 2.5 отвечал
|
| 7 |
+
по памяти, будто искал. Парсер тестируется статичной фикстурой (без сети), fetch —
|
| 8 |
+
стабом сессии (граница I/O, не перереализация поведения).
|
| 9 |
+
|
| 10 |
+
Запуск:
|
| 11 |
+
pytest tests/test_lumen_search.py -v
|
| 12 |
+
"""
|
| 13 |
+
import asyncio
|
| 14 |
+
from types import SimpleNamespace
|
| 15 |
+
|
| 16 |
+
import aiohttp
|
| 17 |
+
import lumen_search
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
_DDG_FIXTURE = """
|
| 21 |
+
<html><body><table>
|
| 22 |
+
<tr><td><a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fnews&rut=abc">Example news</a></td></tr>
|
| 23 |
+
<tr><td class='result-snippet'>Fresh snippet text here</td></tr>
|
| 24 |
+
<tr><td><a rel="nofollow" href="https://direct.example.org/page">Direct <b>title</b></a></td></tr>
|
| 25 |
+
<tr><td class='result-snippet'>Second snippet</td></tr>
|
| 26 |
+
<tr><td><a rel="nofollow" href="/internal/path">Internal link</a></td></tr>
|
| 27 |
+
<tr><td><a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fnews">Example news again</a></td></tr>
|
| 28 |
+
<tr><td class='result-snippet'>Duplicate url snippet</td></tr>
|
| 29 |
+
</table></body></html>
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_parse_ddg_lite_html_unwraps_redirect_and_pairs_snippets():
|
| 34 |
+
out = lumen_search.parse_ddg_lite_html(_DDG_FIXTURE)
|
| 35 |
+
assert out[0]["url"] == "https://example.com/news"
|
| 36 |
+
assert out[0]["title"] == "Example news"
|
| 37 |
+
assert out[0]["snippet"] == "Fresh snippet text here"
|
| 38 |
+
assert out[1]["url"] == "https://direct.example.org/page"
|
| 39 |
+
assert out[1]["title"] == "Direct title"
|
| 40 |
+
# Внутренняя ссылка и дубль целевого URL отсечены.
|
| 41 |
+
assert all("duckduckgo.com" not in item["url"] for item in out)
|
| 42 |
+
assert len({item["url"] for item in out}) == len(out) == 2
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_parse_ddg_lite_html_empty_page_gives_empty_list():
|
| 46 |
+
assert lumen_search.parse_ddg_lite_html("") == []
|
| 47 |
+
assert lumen_search.parse_ddg_lite_html("<html></html>") == []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_format_snippets_for_prompt_marks_data_and_skips_empties():
|
| 51 |
+
block = lumen_search.format_snippets_for_prompt([
|
| 52 |
+
{"title": "T", "url": "https://example.com", "snippet": "S"},
|
| 53 |
+
{"title": "", "url": "", "snippet": ""},
|
| 54 |
+
])
|
| 55 |
+
assert "untrusted" in block and "not instructions" in block
|
| 56 |
+
assert "https://example.com" in block
|
| 57 |
+
assert lumen_search.format_snippets_for_prompt([]) == ""
|
| 58 |
+
assert lumen_search.format_snippets_for_prompt([{"title": "", "url": "", "snippet": ""}]) == ""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_route_has_live_search_follows_grounding_flags():
|
| 62 |
+
assert lumen_search.route_has_live_search([("groq", "qwen/qwen3.8-27b")]) is False
|
| 63 |
+
assert lumen_search.route_has_live_search([("gemini", "gemini-3.8-flash")]) is False
|
| 64 |
+
assert lumen_search.route_has_live_search([("gemini", "gemini-2.5-flash")]) is True
|
| 65 |
+
assert lumen_search.route_has_live_search([
|
| 66 |
+
("groq", "qwen/qwen3.8-27b"), ("gemini", "gemini-2.5-flash-lite"),
|
| 67 |
+
]) is True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_web_search_kill_switch_and_limits(monkeypatch):
|
| 71 |
+
monkeypatch.setenv("WEB_SEARCH_ENABLED", "0")
|
| 72 |
+
assert lumen_search.web_search_is_enabled() is False
|
| 73 |
+
monkeypatch.setenv("WEB_SEARCH_ENABLED", "1")
|
| 74 |
+
assert lumen_search.web_search_is_enabled() is True
|
| 75 |
+
monkeypatch.setenv("WEB_SEARCH_TIMEOUT_SEC", "мусор")
|
| 76 |
+
assert lumen_search.web_search_timeout_sec() == 8.0
|
| 77 |
+
monkeypatch.setenv("WEB_SEARCH_MAX_RESULTS", "3")
|
| 78 |
+
assert lumen_search.web_search_max_results() == 3
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class _StubResp:
|
| 82 |
+
def __init__(self, status=200, text=""):
|
| 83 |
+
self.status = status
|
| 84 |
+
self._text = text
|
| 85 |
+
|
| 86 |
+
async def __aenter__(self):
|
| 87 |
+
return self
|
| 88 |
+
|
| 89 |
+
async def __aexit__(self, *args):
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
async def text(self):
|
| 93 |
+
return self._text
|
| 94 |
+
|
| 95 |
+
def raise_for_status(self):
|
| 96 |
+
raise RuntimeError(f"http {self.status}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class _StubSession:
|
| 100 |
+
def __init__(self, resp=None, exc=None):
|
| 101 |
+
self._resp = resp
|
| 102 |
+
self._exc = exc
|
| 103 |
+
|
| 104 |
+
async def __aenter__(self):
|
| 105 |
+
return self
|
| 106 |
+
|
| 107 |
+
async def __aexit__(self, *args):
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
def post(self, *args, **kwargs):
|
| 111 |
+
if self._exc is not None:
|
| 112 |
+
raise self._exc
|
| 113 |
+
return self._resp
|
| 114 |
+
|
| 115 |
+
def get(self, *args, **kwargs):
|
| 116 |
+
if self._exc is not None:
|
| 117 |
+
raise self._exc
|
| 118 |
+
return self._resp
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _stub_aiohttp(resp=None, exc=None):
|
| 122 |
+
return SimpleNamespace(
|
| 123 |
+
ClientSession=lambda *args, **kwargs: _StubSession(resp, exc),
|
| 124 |
+
ClientTimeout=aiohttp.ClientTimeout,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_fetch_web_snippets_parses_stubbed_page(monkeypatch):
|
| 129 |
+
monkeypatch.setattr(lumen_search, "aiohttp", _stub_aiohttp(_StubResp(200, _DDG_FIXTURE)))
|
| 130 |
+
out = asyncio.run(lumen_search.fetch_web_snippets("что с ДЦ Яндекса"))
|
| 131 |
+
assert [item["url"] for item in out] == ["https://example.com/news", "https://direct.example.org/page"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_fetch_web_snippets_fails_open_on_network_error(monkeypatch):
|
| 135 |
+
# Любая сетевая ошибка — пустой список, вызывающий код ставит честную пометку, а не падает.
|
| 136 |
+
monkeypatch.setattr(lumen_search, "aiohttp", _stub_aiohttp(exc=ConnectionError("down")))
|
| 137 |
+
assert asyncio.run(lumen_search.fetch_web_snippets("что с ДЦ Яндекса")) == []
|
| 138 |
+
assert asyncio.run(lumen_search.fetch_web_snippets(" ")) == []
|