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.
- searchapi_haystack-2.1.0/CHANGELOG.md +29 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/PKG-INFO +5 -4
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/README.md +1 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/pyproject.toml +3 -2
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/searchapi/websearch.py +64 -15
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/tests/test_websearch.py +113 -1
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/.gitignore +0 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/LICENSE.txt +0 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/pydoc/config_docusaurus.yml +0 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/py.typed +0 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/src/haystack_integrations/components/websearch/searchapi/__init__.py +0 -0
- {searchapi_haystack-1.0.0 → searchapi_haystack-2.1.0}/tests/__init__.py +0 -0
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: searchapi-haystack
|
|
3
|
-
Version: 1.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.
|
|
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
|
[](https://pypi.org/project/searchapi-haystack)
|
|
30
30
|
[](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
|
[](https://pypi.org/project/searchapi-haystack)
|
|
4
4
|
[](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
|
+
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,
|
|
9
|
-
from haystack.utils import Secret
|
|
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
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|