返回 last30days-skill
test_grounding_v3.py
根目录 / tests / test_grounding_v3.py
1 import unittest
2 from unittest.mock import patch
3
4 from lib import grounding
5
6
7 class BraveSearchTests(unittest.TestCase):
8 def test_brave_search_applies_freshness_and_filters_to_in_range_dated_items(self):
9 mock_response = {
10 "web": {
11 "results": [
12 {
13 "title": "Test Article",
14 "url": "https://example.com/article",
15 "description": "A test snippet",
16 "page_age": "2026-03-10T00:00:00",
17 },
18 {
19 "title": "Old Article",
20 "url": "https://example.com/old",
21 "description": "Should be filtered",
22 "page_age": "2025-12-10T00:00:00",
23 },
24 {
25 "title": "Undated Article",
26 "url": "https://example.com/undated",
27 "description": "Should also be filtered",
28 }
29 ]
30 }
31 }
32 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
33 items, artifact = grounding.brave_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
34 self.assertEqual(1, len(items))
35 self.assertEqual("Test Article", items[0]["title"])
36 self.assertEqual("https://example.com/article", items[0]["url"])
37 self.assertEqual("2026-03-10", items[0]["date"])
38 self.assertEqual("brave", artifact["label"])
39 call_url = mock_req.call_args.args[1]
40 self.assertIn("freshness=2026-02-25to2026-03-27", call_url)
41
42
43 class SerperSearchTests(unittest.TestCase):
44 def test_serper_search_filters_to_in_range_dated_items(self):
45 mock_response = {
46 "organic": [
47 {
48 "title": "Serper Result",
49 "link": "https://example.com/serper",
50 "snippet": "A serper snippet",
51 "date": "Mar 15, 2026",
52 },
53 {
54 "title": "Old Result",
55 "link": "https://example.com/old",
56 "snippet": "Should be filtered",
57 "date": "Jan 15, 2026",
58 },
59 {
60 "title": "Undated Result",
61 "link": "https://example.com/undated",
62 "snippet": "Should also be filtered",
63 }
64 ]
65 }
66 with patch("lib.grounding.http.request", return_value=mock_response):
67 items, artifact = grounding.serper_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
68 self.assertEqual(1, len(items))
69 self.assertEqual("Serper Result", items[0]["title"])
70 self.assertEqual("2026-03-15", items[0]["date"])
71 self.assertEqual("serper", artifact["label"])
72
73
74 class ExaSearchTests(unittest.TestCase):
75 def test_exa_search_filters_to_in_range_dated_items(self):
76 mock_response = {
77 "results": [
78 {
79 "title": "Exa Result",
80 "url": "https://example.com/exa",
81 "text": "An exa snippet about AI trends",
82 "publishedDate": "2026-03-15T00:00:00.000Z",
83 "score": 0.85,
84 },
85 {
86 "title": "Old Exa Result",
87 "url": "https://example.com/old-exa",
88 "text": "Should be filtered out",
89 "publishedDate": "2025-12-01T00:00:00.000Z",
90 "score": 0.7,
91 },
92 {
93 "title": "Undated Exa Result",
94 "url": "https://example.com/undated-exa",
95 "text": "No date means filtered",
96 },
97 ]
98 }
99 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
100 items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "fake-exa-key")
101 self.assertEqual(1, len(items))
102 self.assertEqual("Exa Result", items[0]["title"])
103 self.assertEqual("https://example.com/exa", items[0]["url"])
104 self.assertEqual("2026-03-15", items[0]["date"])
105 self.assertTrue(items[0]["id"].startswith("WE"))
106 self.assertEqual("exa", artifact["label"])
107 self.assertEqual(1, artifact["resultCount"])
108 # Verify API call
109 call_args = mock_req.call_args
110 self.assertEqual("POST", call_args.args[0])
111 self.assertEqual("https://api.exa.ai/search", call_args.args[1])
112 self.assertEqual("fake-exa-key", call_args.kwargs["headers"]["x-api-key"])
113
114 def test_exa_search_returns_empty_for_no_results(self):
115 with patch("lib.grounding.http.request", return_value={"results": []}):
116 items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "key")
117 self.assertEqual([], items)
118 self.assertEqual(0, artifact["resultCount"])
119
120
121 class ParallelSearchTests(unittest.TestCase):
122 def test_parallel_search_filters_to_in_range_dated_items(self):
123 mock_response = {
124 "results": [
125 {
126 "title": "Parallel Result",
127 "url": "https://example.com/parallel",
128 "snippet": "A parallel snippet",
129 "publish_date": "2026-03-15T00:00:00Z",
130 },
131 {
132 "title": "Old Parallel Result",
133 "url": "https://example.com/old-parallel",
134 "snippet": "Should be filtered",
135 "publish_date": "2025-12-01T00:00:00Z",
136 },
137 {
138 "title": "Undated Parallel Result",
139 "url": "https://example.com/undated-parallel",
140 "snippet": "Should also be filtered",
141 },
142 ]
143 }
144 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
145 items, artifact = grounding.parallel_search(
146 "test", ("2026-02-25", "2026-03-27"), "fake-parallel-key"
147 )
148 self.assertEqual(1, len(items))
149 self.assertEqual("Parallel Result", items[0]["title"])
150 self.assertEqual("https://example.com/parallel", items[0]["url"])
151 self.assertEqual("2026-03-15", items[0]["date"])
152 self.assertTrue(items[0]["id"].startswith("WP"))
153 self.assertEqual("parallel", artifact["label"])
154 self.assertEqual(1, artifact["resultCount"])
155 self.assertEqual("POST", mock_req.call_args.args[0])
156 self.assertEqual("https://api.parallel.ai/v1/search", mock_req.call_args.args[1])
157 self.assertEqual(
158 "Bearer fake-parallel-key",
159 mock_req.call_args.kwargs["headers"]["Authorization"],
160 )
161
162 def test_parallel_search_returns_empty_for_no_results(self):
163 with patch("lib.grounding.http.request", return_value={"results": []}):
164 items, artifact = grounding.parallel_search("test", ("2026-02-25", "2026-03-27"), "key")
165 self.assertEqual([], items)
166 self.assertEqual(0, artifact["resultCount"])
167
168
169 class WebSearchDispatchTests(unittest.TestCase):
170 def test_auto_selects_brave_when_key_present(self):
171 config = {"BRAVE_API_KEY": "test-key"}
172 with patch("lib.grounding.brave_search", return_value=([], {})) as mock:
173 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
174 mock.assert_called_once()
175
176 def test_auto_selects_exa_when_only_exa_key(self):
177 config = {"EXA_API_KEY": "test-key"}
178 with patch("lib.grounding.exa_search", return_value=([], {})) as mock:
179 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
180 mock.assert_called_once()
181
182 def test_auto_selects_serper_when_only_serper_key(self):
183 config = {"SERPER_API_KEY": "test-key"}
184 with patch("lib.grounding.serper_search", return_value=([], {})) as mock:
185 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
186 mock.assert_called_once()
187
188 def test_auto_selects_parallel_when_only_parallel_key(self):
189 config = {"PARALLEL_API_KEY": "test-key"}
190 with patch("lib.grounding.parallel_search", return_value=([], {})) as mock:
191 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
192 mock.assert_called_once()
193
194 def test_auto_returns_empty_when_no_keys_and_native_search(self):
195 # On a native-search host (signal set) with no paid key, the engine
196 # leaves general web to the model's own search and returns nothing.
197 config = {"LAST30DAYS_NATIVE_SEARCH": "1"}
198 items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
199 self.assertEqual([], items)
200 self.assertEqual({}, artifact)
201
202 def test_auto_falls_to_keyless_when_no_keys_and_no_native_search(self):
203 # No paid key and no native search -> keyless floor is used.
204 with patch("lib.grounding.web_search_keyless.keyless_search",
205 return_value=([], {"label": "keyless"})) as mock_keyless:
206 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="auto")
207 mock_keyless.assert_called_once()
208
209 def test_explicit_keyless_backend_invokes_keyless(self):
210 with patch("lib.grounding.web_search_keyless.keyless_search",
211 return_value=([], {"label": "keyless"})) as mock_keyless:
212 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="keyless")
213 mock_keyless.assert_called_once()
214
215 def test_none_returns_empty(self):
216 config = {"BRAVE_API_KEY": "test-key"}
217 items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="none")
218 self.assertEqual([], items)
219
220 def test_auto_prefers_brave_over_exa(self):
221 config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key"}
222 with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
223 patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa:
224 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
225 mock_brave.assert_called_once()
226 mock_exa.assert_not_called()
227
228 def test_auto_prefers_exa_over_serper(self):
229 config = {"EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
230 with patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
231 patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
232 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
233 mock_exa.assert_called_once()
234 mock_serper.assert_not_called()
235
236 def test_auto_prefers_serper_over_parallel(self):
237 config = {"SERPER_API_KEY": "serper-key", "PARALLEL_API_KEY": "parallel-key"}
238 with patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper, \
239 patch("lib.grounding.parallel_search", return_value=([], {})) as mock_parallel:
240 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
241 mock_serper.assert_called_once()
242 mock_parallel.assert_not_called()
243
244 def test_auto_prefers_brave_when_all_keys_present(self):
245 config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
246 with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
247 patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
248 patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
249 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
250 mock_brave.assert_called_once()
251 mock_exa.assert_not_called()
252 mock_serper.assert_not_called()
253
254 def test_explicit_exa_without_key_raises(self):
255 with self.assertRaises(RuntimeError):
256 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="exa")
257
258 def test_explicit_brave_without_key_raises(self):
259 with self.assertRaises(RuntimeError):
260 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="brave")
261
262 def test_explicit_parallel_without_key_raises(self):
263 with self.assertRaises(RuntimeError):
264 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="parallel")
265
266 def test_unsupported_backend_raises(self):
267 with self.assertRaises(ValueError):
268 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="google")
269
270
271 class RedditEnrichmentGateTests(unittest.TestCase):
272 """EXCLUDE_SOURCES=reddit must suppress the web-search Reddit enrichment.
273
274 Otherwise a user who explicitly excluded Reddit would still get Reddit
275 content smuggled back in via web-search URLs that happen to point at
276 reddit.com threads.
277 """
278
279 def test_reddit_excluded_via_exclude_sources_skips_enrichment(self):
280 config = {"BRAVE_API_KEY": "k", "EXCLUDE_SOURCES": "reddit"}
281 items = [{"url": "https://www.reddit.com/r/python/comments/abc/title/", "snippet": "original"}]
282 with patch("lib.grounding.brave_search", return_value=(items, {})), \
283 patch("lib.grounding._enrich_reddit_items") as enrich_mock:
284 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
285 enrich_mock.assert_not_called()
286
287 def test_reddit_excluded_case_insensitive(self):
288 for value in ("REDDIT", "Reddit", " reddit ", "x,reddit,y"):
289 config = {"BRAVE_API_KEY": "k", "EXCLUDE_SOURCES": value}
290 self.assertTrue(
291 grounding._reddit_excluded(config),
292 msg=f"_reddit_excluded should be True for EXCLUDE_SOURCES={value!r}",
293 )
294
295 def test_reddit_not_excluded_when_other_sources_listed(self):
296 config = {"EXCLUDE_SOURCES": "tiktok,instagram"}
297 self.assertFalse(grounding._reddit_excluded(config))
298
299 def test_enrichment_runs_when_reddit_not_excluded(self):
300 config = {"BRAVE_API_KEY": "k"}
301 items = [{"url": "https://www.reddit.com/r/python/comments/abc/title/", "snippet": "original"}]
302 with patch("lib.grounding.brave_search", return_value=(items, {})), \
303 patch("lib.grounding._enrich_reddit_items", return_value=items) as enrich_mock:
304 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
305 enrich_mock.assert_called_once()
306
307
308 class RedditEnrichItemsTests(unittest.TestCase):
309 """Direct tests for `_enrich_reddit_items` covering the selftext key path
310 and the RedditRateLimitError early-exit behavior.
311 """
312
313 def test_selftext_under_submission_populates_snippet(self):
314 from lib import reddit_enrich
315
316 item = {
317 "url": "https://www.reddit.com/r/python/comments/abc/title/",
318 "snippet": "original",
319 }
320 parsed = {
321 "submission": {"selftext": "thread body content"},
322 "comments": [],
323 }
324 with patch.object(reddit_enrich, "fetch_thread_data", return_value={"raw": True}), \
325 patch.object(reddit_enrich, "parse_thread_data", return_value=parsed):
326 result = grounding._enrich_reddit_items([item])
327 self.assertEqual("thread body content", result[0]["snippet"])
328 self.assertEqual("reddit_json_api", result[0]["enriched_via"])
329
330 def test_rate_limit_error_halts_iteration(self):
331 from lib import reddit_enrich
332
333 item1 = {"url": "https://www.reddit.com/r/python/comments/aaa/x/"}
334 item2 = {"url": "https://www.reddit.com/r/python/comments/bbb/y/"}
335
336 def fake_fetch(url, *args, **kwargs):
337 raise reddit_enrich.RedditRateLimitError(f"429 for {url}")
338
339 captured_stderr: list[str] = []
340
341 with patch.object(reddit_enrich, "fetch_thread_data", side_effect=fake_fetch) as fetch_mock, \
342 patch("lib.grounding.sys.stderr.write", side_effect=lambda s: captured_stderr.append(s)):
343 grounding._enrich_reddit_items([item1, item2])
344
345 # Only the first item should have triggered a fetch attempt
346 self.assertEqual(1, fetch_mock.call_count)
347 # A stderr message about the rate-limit halt should have been emitted
348 self.assertTrue(
349 any("rate-limited" in msg.lower() or "rate limited" in msg.lower() for msg in captured_stderr),
350 msg=f"Expected a rate-limit stderr message, got: {captured_stderr!r}",
351 )
352
353 class RedditEnrichmentIsolationTests(unittest.TestCase):
354 def test_enrichment_http_failure_does_not_poison_web_source(self):
355 """A reddit.com enrichment fetch failure (e.g. a 403 on a datacenter IP)
356 is a secondary operation on already-retrieved web results; it must not be
357 attributed to the web/grounding source and discard those results."""
358 from lib import http
359
360 retrieved = [
361 {"url": "https://www.reddit.com/r/x/comments/1/abc/", "title": "T", "snippet": "s"},
362 ]
363
364 def fake_enrich(items):
365 # The enricher swallows the error, but the http layer records the
366 # terminal failure into whatever capture sink is currently active.
367 http._record_failure(http.HTTPError("Blocked", status_code=403))
368 return items
369
370 with http.capture_failures() as source_sink:
371 with patch.object(grounding, "web_search_keyless") as wsk, \
372 patch.object(grounding, "_enrich_reddit_items", side_effect=fake_enrich):
373 wsk.keyless_search.return_value = (list(retrieved), {"keyless_backend": "startpage"})
374 items, _ = grounding.web_search(
375 "q", ("2026-02-25", "2026-03-27"), {}, backend="keyless")
376
377 self.assertEqual(len(items), 1)
378 # The enrichment 403 is isolated in its own sink; the source's sink is clean.
379 self.assertEqual(source_sink, [])
380
381
382 if __name__ == "__main__":
383 unittest.main()
384
384 lines PYTHON