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 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&amp;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(" ")) == []