searchapi-haystack 1.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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: searchapi-haystack
3
- Version: 1.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
@@ -12,14 +12,14 @@ Keywords: AI Search,Haystack,SearchApi,Web Search
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Programming Language :: Python
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
20
  Classifier: Programming Language :: Python :: Implementation :: CPython
21
21
  Classifier: Programming Language :: Python :: Implementation :: PyPy
22
- Requires-Python: >=3.10
22
+ Requires-Python: >=3.11
23
23
  Requires-Dist: haystack-ai>=2.24.1
24
24
  Requires-Dist: httpx>=0.27.0
25
25
  Description-Content-Type: text/markdown
@@ -29,6 +29,7 @@ Description-Content-Type: text/markdown
29
29
  [![PyPI - Version](https://img.shields.io/pypi/v/searchapi-haystack.svg)](https://pypi.org/project/searchapi-haystack)
30
30
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/searchapi-haystack.svg)](https://pypi.org/project/searchapi-haystack)
31
31
 
32
+ - [Integration page](https://haystack.deepset.ai/integrations/searchapi)
32
33
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/searchapi/CHANGELOG.md)
33
34
 
34
35
  ---
@@ -3,6 +3,7 @@
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/searchapi-haystack.svg)](https://pypi.org/project/searchapi-haystack)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/searchapi-haystack.svg)](https://pypi.org/project/searchapi-haystack)
5
5
 
6
+ - [Integration page](https://haystack.deepset.ai/integrations/searchapi)
6
7
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/searchapi/CHANGELOG.md)
7
8
 
8
9
  ---
@@ -7,7 +7,7 @@ name = "searchapi-haystack"
7
7
  dynamic = ["version"]
8
8
  description = "Haystack integration for SearchApi Web Search"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
12
12
  keywords = ["Haystack", "SearchApi", "Web Search", "AI Search"]
13
13
  authors = [{ name = "deepset GmbH", email = "info@deepset.ai" }]
@@ -15,11 +15,11 @@ classifiers = [
15
15
  "License :: OSI Approved :: Apache Software License",
16
16
  "Development Status :: 4 - Beta",
17
17
  "Programming Language :: Python",
18
- "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",
22
21
  "Programming Language :: Python :: 3.14",
22
+ "Programming Language :: Python :: 3.15",
23
23
  "Programming Language :: Python :: Implementation :: CPython",
24
24
  "Programming Language :: Python :: Implementation :: PyPy",
25
25
  ]
@@ -131,6 +131,7 @@ ignore = [
131
131
  "PLR0912",
132
132
  "PLR0913",
133
133
  "PLR0915",
134
+ "PLR0917",
134
135
  # Allow `Any` type - used legitimately for dynamic types and SDK boundaries
135
136
  "ANN401",
136
137
  ]
@@ -5,8 +5,8 @@
5
5
  from typing import Any
6
6
 
7
7
  import httpx
8
- from haystack import ComponentError, Document, component, default_from_dict, default_to_dict, logging
9
- from haystack.utils import Secret, deserialize_secrets_inplace
8
+ from haystack import ComponentError, Document, component, default_to_dict, logging
9
+ from haystack.utils import Secret
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -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__(
@@ -82,19 +87,6 @@ class SearchApiWebSearch:
82
87
  api_key=self.api_key.to_dict(),
83
88
  )
84
89
 
85
- @classmethod
86
- def from_dict(cls, data: dict[str, Any]) -> "SearchApiWebSearch":
87
- """
88
- Deserializes the component from a dictionary.
89
-
90
- :param data:
91
- The dictionary to deserialize from.
92
- :returns:
93
- The deserialized component.
94
- """
95
- deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
96
- return default_from_dict(cls, data)
97
-
98
90
  @component.output_types(documents=list[Document], links=list[str])
99
91
  def run(self, query: str) -> dict[str, list[Document] | list[str]]:
100
92
  """
@@ -182,6 +174,11 @@ class SearchApiWebSearch:
182
174
  def _parse_response(response: httpx.Response) -> tuple[list[Document], list[str]]:
183
175
  json_result = response.json()
184
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
+
185
182
  # organic results are the main results from the search engine
186
183
  organic_results = []
187
184
  if "organic_results" in json_result:
@@ -231,3 +228,55 @@ class SearchApiWebSearch:
231
228
 
232
229
  links = [result["link"] for result in json_result.get("organic_results", [])]
233
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
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
8
8
 
9
9
  import pytest
10
10
  from haystack import Document
11
+ from haystack.core.serialization import component_from_dict
11
12
  from haystack.utils.auth import Secret
12
13
  from httpx import ConnectTimeout, HTTPStatusError, Request, RequestError, Response
13
14
 
@@ -397,6 +398,48 @@ def mock_searchapi_search_result_async() -> Generator[MagicMock, None, None]:
397
398
  yield mock_get
398
399
 
399
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
+
400
443
  class TestSearchApiSearchAPI:
401
444
  def test_init_fail_wo_api_key(self, monkeypatch):
402
445
  monkeypatch.delenv("SEARCHAPI_API_KEY", raising=False)
@@ -430,7 +473,7 @@ class TestSearchApiSearchAPI:
430
473
  "search_params": {"param": "test params", "engine": "google"},
431
474
  },
432
475
  }
433
- component = SearchApiWebSearch.from_dict(data)
476
+ component = component_from_dict(SearchApiWebSearch, data, name="component")
434
477
  assert component.api_key == Secret.from_env_var("SEARCHAPI_API_KEY")
435
478
  assert component.top_k == 10
436
479
  assert component.allowed_domains == ["testdomain.com"]
@@ -443,6 +486,75 @@ class TestSearchApiSearchAPI:
443
486
  assert documents[0].content == "Satya Nadella"
444
487
  assert links == []
445
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
+
446
558
  @pytest.mark.parametrize("top_k", [1, 5, 7])
447
559
  @pytest.mark.usefixtures("mock_searchapi_search_result")
448
560
  def test_web_search_top_k(self, top_k: int) -> None: