searchapi-haystack 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [integrations/searchapi-v2.0.0] - 2026-10-16
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Fix new issues raised by ruff 0.16.0 (#3670)
8
+
9
+ ### 🚜 Refactor
10
+
11
+ - [**breaking**] Remove from_dict overrides that only call default_from_dict from 14 components (#4059)
12
+
13
+ ### 🧪 Testing
14
+
15
+ - Test compatible integrations with Python 3.15; update pyproject (#4104)
16
+
17
+ ### 🧹 Chores
18
+
19
+ - Re-standardize READMEs (#3768)
20
+ - [**breaking**] Drop Python 3.10 support (#4105)
21
+
22
+
23
+ ## [integrations/searchapi-v1.0.0] - 2026-06-15
24
+
25
+ ### 🚀 Features
26
+
27
+ - Add searchapi integration (moved from Haystack core) (#3437)
28
+
29
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: searchapi-haystack
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Haystack integration for SearchApi Web Search
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/searchapi#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -34,6 +34,11 @@ class SearchApiWebSearch:
34
34
  assert results["documents"]
35
35
  assert results["links"]
36
36
  ```
37
+
38
+ SearchApi's AI answer engines are supported through `search_params`. With `{"engine": "google_ai_mode"}`,
39
+ `{"engine": "chatgpt"}`, `{"engine": "gemini"}` or `{"engine": "bing_copilot"}`, the first document holds the
40
+ synthesized answer as Markdown. The following documents and `links` hold the reference links it cites and,
41
+ for `google_ai_mode` and `chatgpt`, the web results the engine retrieved.
37
42
  """
38
43
 
39
44
  def __init__(
@@ -169,6 +174,11 @@ class SearchApiWebSearch:
169
174
  def _parse_response(response: httpx.Response) -> tuple[list[Document], list[str]]:
170
175
  json_result = response.json()
171
176
 
177
+ # AI answer engines (google_ai_mode, chatgpt, gemini, bing_copilot) return a
178
+ # synthesized answer instead of organic results
179
+ if any(key in json_result for key in ("text_blocks", "markdown", "reference_links")):
180
+ return SearchApiWebSearch._parse_ai_answer(json_result)
181
+
172
182
  # organic results are the main results from the search engine
173
183
  organic_results = []
174
184
  if "organic_results" in json_result:
@@ -218,3 +228,55 @@ class SearchApiWebSearch:
218
228
 
219
229
  links = [result["link"] for result in json_result.get("organic_results", [])]
220
230
  return documents, links
231
+
232
+ @staticmethod
233
+ def _parse_ai_answer(json_result: dict[str, Any]) -> tuple[list[Document], list[str]]:
234
+ """
235
+ Parse the shared AI answer shape returned by SearchApi's answer engines.
236
+
237
+ Engines such as `google_ai_mode`, `chatgpt`, `gemini` and `bing_copilot` return the answer as `markdown`
238
+ and the sources it cites as `reference_links`. `google_ai_mode` and `chatgpt` also return the `web_results`
239
+ they retrieved. The answer becomes the first document, each reference link a document after it, followed by
240
+ any web results. Links are the reference links, then the web result links.
241
+ """
242
+ parameters = json_result.get("search_parameters", {})
243
+ documents: list[Document] = []
244
+
245
+ answer = json_result.get("markdown")
246
+ if answer:
247
+ documents.append(
248
+ Document.from_dict({"content": answer, "engine": parameters.get("engine", ""), "type": "ai_answer"})
249
+ )
250
+
251
+ links: list[str] = []
252
+ for result in json_result.get("reference_links", []) or []:
253
+ documents.append(
254
+ Document.from_dict(
255
+ {
256
+ "title": result.get("title", ""),
257
+ "content": result.get("snippet", ""),
258
+ "link": result.get("link", ""),
259
+ "source": result.get("source", ""),
260
+ }
261
+ )
262
+ )
263
+ if result.get("link"):
264
+ links.append(result["link"])
265
+
266
+ for result in json_result.get("web_results", []) or []:
267
+ link = result.get("link")
268
+ if link in links:
269
+ continue
270
+ documents.append(
271
+ Document.from_dict(
272
+ {
273
+ "title": result.get("title", ""),
274
+ "content": result.get("snippet", ""),
275
+ "link": link or "",
276
+ }
277
+ )
278
+ )
279
+ if link:
280
+ links.append(link)
281
+
282
+ return documents, links
@@ -398,6 +398,48 @@ def mock_searchapi_search_result_async() -> Generator[MagicMock, None, None]:
398
398
  yield mock_get
399
399
 
400
400
 
401
+ EXAMPLE_AI_ANSWER_RESPONSE = {
402
+ "search_metadata": {"id": "search_ai_answer", "status": "Success", "created_at": "2026-10-01T00:00:00Z"},
403
+ "search_parameters": {"engine": "google_ai_mode", "q": "What is pgvector?"},
404
+ "text_blocks": [
405
+ {"type": "header", "answer": "pgvector"},
406
+ {
407
+ "type": "paragraph",
408
+ "answer": "pgvector adds vector similarity search to PostgreSQL.",
409
+ "reference_indexes": [0],
410
+ },
411
+ {
412
+ "type": "unordered_list",
413
+ "answer": "It supports:",
414
+ "items": [
415
+ {"type": "paragraph", "answer": "HNSW indexes"},
416
+ {"type": "paragraph", "answer": "IVFFlat indexes"},
417
+ ],
418
+ },
419
+ {"type": "table", "table": {"headers": ["Index", "Recall"], "rows": [["HNSW", "0.75"], ["IVFFlat", "0.70"]]}},
420
+ ],
421
+ "markdown": "## pgvector\n\npgvector adds vector similarity search to PostgreSQL.[1]",
422
+ "reference_links": [
423
+ {
424
+ "index": 0,
425
+ "title": "pgvector on GitHub",
426
+ "link": "https://github.com/pgvector/pgvector",
427
+ "snippet": "Open-source vector similarity search for Postgres.",
428
+ "source": "GitHub",
429
+ },
430
+ {"index": 1, "title": "Vectors in Postgres", "link": "https://example.com/vectors", "source": "Example"},
431
+ ],
432
+ "related_questions": [{"question": "How does HNSW work?"}],
433
+ }
434
+
435
+
436
+ @pytest.fixture
437
+ def mock_searchapi_ai_answer_result() -> Generator[MagicMock, None, None]:
438
+ with patch("haystack_integrations.components.websearch.searchapi.websearch.httpx.get") as mock_get:
439
+ mock_get.return_value = Mock(status_code=200, json=lambda: EXAMPLE_AI_ANSWER_RESPONSE)
440
+ yield mock_get
441
+
442
+
401
443
  class TestSearchApiSearchAPI:
402
444
  def test_init_fail_wo_api_key(self, monkeypatch):
403
445
  monkeypatch.delenv("SEARCHAPI_API_KEY", raising=False)
@@ -444,6 +486,75 @@ class TestSearchApiSearchAPI:
444
486
  assert documents[0].content == "Satya Nadella"
445
487
  assert links == []
446
488
 
489
+ def test_parse_ai_answer_response(self) -> None:
490
+ documents, links = SearchApiWebSearch._parse_ai_answer(EXAMPLE_AI_ANSWER_RESPONSE)
491
+
492
+ assert documents[0].content == EXAMPLE_AI_ANSWER_RESPONSE["markdown"]
493
+ assert documents[0].meta["engine"] == "google_ai_mode"
494
+ assert documents[0].meta["type"] == "ai_answer"
495
+
496
+ assert [doc.meta["link"] for doc in documents[1:]] == [
497
+ "https://github.com/pgvector/pgvector",
498
+ "https://example.com/vectors",
499
+ ]
500
+ assert documents[1].content == "Open-source vector similarity search for Postgres."
501
+ assert documents[1].meta["source"] == "GitHub"
502
+ assert documents[2].content == ""
503
+ assert links == ["https://github.com/pgvector/pgvector", "https://example.com/vectors"]
504
+
505
+ def test_parse_ai_answer_includes_web_results_after_references(self) -> None:
506
+ response = {
507
+ **EXAMPLE_AI_ANSWER_RESPONSE,
508
+ "search_parameters": {"engine": "google_ai_mode", "q": "What is pgvector?"},
509
+ "web_results": [
510
+ {
511
+ "title": "pgvector on GitHub",
512
+ "link": "https://github.com/pgvector/pgvector",
513
+ "displayed_link": "github.com",
514
+ "source": "GitHub",
515
+ "snippet": "Already cited as a reference link.",
516
+ },
517
+ {
518
+ "title": "A web result",
519
+ "link": "https://example.com/web",
520
+ "displayed_link": "example.com",
521
+ "source": "Example",
522
+ "snippet": "Web snippet.",
523
+ },
524
+ ],
525
+ }
526
+ documents, links = SearchApiWebSearch._parse_ai_answer(response)
527
+
528
+ # A web result that is already a reference link is skipped in both documents and links.
529
+ assert links == [
530
+ "https://github.com/pgvector/pgvector",
531
+ "https://example.com/vectors",
532
+ "https://example.com/web",
533
+ ]
534
+ assert [doc.meta.get("link") for doc in documents].count("https://github.com/pgvector/pgvector") == 1
535
+ assert documents[-1].meta["link"] == "https://example.com/web"
536
+ assert documents[-1].content == "Web snippet."
537
+
538
+ def test_parse_ai_answer_without_answer_or_references(self) -> None:
539
+ documents, links = SearchApiWebSearch._parse_ai_answer(
540
+ {"search_parameters": {"engine": "gemini", "q": "nothing"}, "reference_links": []}
541
+ )
542
+ assert documents == []
543
+ assert links == []
544
+
545
+ @pytest.mark.usefixtures("mock_searchapi_ai_answer_result")
546
+ def test_web_search_ai_answer_engine(self) -> None:
547
+ ws = SearchApiWebSearch(
548
+ top_k=2, api_key=Secret.from_token("test-api-key"), search_params={"engine": "google_ai_mode"}
549
+ )
550
+ results = ws.run(query="What is pgvector?")
551
+
552
+ documents = results["documents"]
553
+ assert len(documents) == 2
554
+ assert documents[0].meta["type"] == "ai_answer"
555
+ assert documents[1].meta["link"] == "https://github.com/pgvector/pgvector"
556
+ assert results["links"] == ["https://github.com/pgvector/pgvector", "https://example.com/vectors"]
557
+
447
558
  @pytest.mark.parametrize("top_k", [1, 5, 7])
448
559
  @pytest.mark.usefixtures("mock_searchapi_search_result")
449
560
  def test_web_search_top_k(self, top_k: int) -> None:
@@ -1,9 +0,0 @@
1
- # Changelog
2
-
3
- ## [integrations/searchapi-v1.0.0] - 2026-06-15
4
-
5
- ### 🚀 Features
6
-
7
- - Add searchapi integration (moved from Haystack core) (#3437)
8
-
9
- <!-- generated by git-cliff -->