diff --git a/python/.env.example b/python/.env.example index c3820e5a72ff..c66024161158 100644 --- a/python/.env.example +++ b/python/.env.example @@ -21,6 +21,7 @@ WEAVIATE_URL="" WEAVIATE_API_KEY="" GOOGLE_SEARCH_ENGINE_ID="" BRAVE_API_KEY="" +KEENABLE_API_KEY="" REDIS_CONNECTION_STRING="" AZCOSMOS_API="" AZCOSMOS_CONNSTR="" diff --git a/python/samples/concepts/README.md b/python/samples/concepts/README.md index bfde792aed39..3cf24496028a 100644 --- a/python/samples/concepts/README.md +++ b/python/samples/concepts/README.md @@ -230,6 +230,7 @@ - [Bing Text Search as Plugin](./search/bing_text_search_as_plugin.py) - [Brave Text Search as Plugin](./search/brave_text_search_as_plugin.py) - [Google Text Search as Plugin](./search/google_text_search_as_plugin.py) +- [Keenable Text Search as Plugin](./search/keenable_text_search_as_plugin.py) ### Service Selector - Shows how to create and use a custom service selector class diff --git a/python/samples/concepts/search/keenable_text_search_as_plugin.py b/python/samples/concepts/search/keenable_text_search_as_plugin.py new file mode 100644 index 000000000000..ff80d62b571c --- /dev/null +++ b/python/samples/concepts/search/keenable_text_search_as_plugin.py @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import Awaitable, Callable + +from semantic_kernel import Kernel +from semantic_kernel.connectors.ai import FunctionChoiceBehavior +from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion, OpenAIChatPromptExecutionSettings +from semantic_kernel.connectors.keenable import KeenableSearch +from semantic_kernel.contents import ChatHistory +from semantic_kernel.filters import FilterTypes, FunctionInvocationContext +from semantic_kernel.functions import KernelArguments, KernelParameterMetadata + +""" +This project demonstrates how to integrate the Keenable Search API as a plugin into the Semantic Kernel +framework to enable conversational AI capabilities with real-time web information. + +Keenable search works without an API key: the connector then uses the public endpoint, +which is rate limited per IP. To lift the limits, get a key at https://keenable.ai and +store it under the name `KEENABLE_API_KEY` in a .env file or your environment variables. +""" + +kernel = Kernel() +kernel.add_service(OpenAIChatCompletion(service_id="chat")) +kernel.add_function( + plugin_name="keenable", + function=KeenableSearch().create_search_function( + function_name="keenable_search", + description="Get details about Semantic Kernel concepts.", + parameters=[ + KernelParameterMetadata( + name="query", + description="The search query.", + type="str", + is_required=True, + type_object=str, + ), + KernelParameterMetadata( + name="top", + description="The number of results to return.", + type="int", + is_required=False, + default_value=2, + type_object=int, + ), + KernelParameterMetadata( + name="skip", + description="The number of results to skip.", + type="int", + is_required=False, + default_value=0, + type_object=int, + ), + ], + ), +) +chat_function = kernel.add_function( + prompt="{{$chat_history}}{{$user_input}}", + plugin_name="ChatBot", + function_name="Chat", +) +execution_settings = OpenAIChatPromptExecutionSettings( + service_id="chat", + max_tokens=2000, + temperature=0.7, + top_p=0.8, + function_choice_behavior=FunctionChoiceBehavior.Auto(auto_invoke=True), +) + +history = ChatHistory() +system_message = """ +You are a chat bot, specialized in Semantic Kernel, Microsoft LLM orchestration SDK. +Assume questions are related to that, and use the Keenable search plugin to find answers. +""" +history.add_system_message(system_message) +history.add_user_message("Hi there, who are you?") +history.add_assistant_message("I am Mosscap, a chat bot. I'm trying to figure out what people need.") + +arguments = KernelArguments(settings=execution_settings) + + +@kernel.filter(filter_type=FilterTypes.FUNCTION_INVOCATION) +async def log_keenable_filter( + context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]] +): + if context.function.plugin_name == "keenable": + print("Calling Keenable search with arguments:") + if "query" in context.arguments: + print(f' Query: "{context.arguments["query"]}"') + if "top" in context.arguments: + print(f' Top: "{context.arguments["top"]}"') + if "skip" in context.arguments: + print(f' Skip: "{context.arguments["skip"]}"') + await next(context) + print("Keenable search completed.") + else: + await next(context) + + +async def chat() -> bool: + try: + user_input = input("User:> ") + except KeyboardInterrupt: + print("\n\nExiting chat...") + return False + except EOFError: + print("\n\nExiting chat...") + return False + + if user_input == "exit": + print("\n\nExiting chat...") + return False + arguments["user_input"] = user_input + arguments["chat_history"] = history + result = await kernel.invoke(chat_function, arguments=arguments) + print(f"Mosscap:> {result}") + history.add_user_message(user_input) + history.add_assistant_message(str(result)) + return True + + +async def main(): + chatting = True + print( + "Welcome to the chat bot!\ + \n Type 'exit' to exit.\ + \n Try to find out more about the inner workings of Semantic Kernel." + ) + while chatting: + chatting = await chat() + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/python/semantic_kernel/connectors/keenable.py b/python/semantic_kernel/connectors/keenable.py new file mode 100644 index 000000000000..ef7c7e6eae70 --- /dev/null +++ b/python/semantic_kernel/connectors/keenable.py @@ -0,0 +1,285 @@ +# Copyright (c) Microsoft. All rights reserved. + +import ast +import logging +import sys +from collections.abc import AsyncIterable, Callable +from inspect import getsource +from typing import Any, ClassVar, Final, Literal +from urllib.parse import unquote_plus + +from httpx import AsyncClient, HTTPStatusError, RequestError +from pydantic import Field, SecretStr, ValidationError + +from semantic_kernel.connectors._search_shared import SearchLambdaVisitor +from semantic_kernel.data.text_search import ( + KernelSearchResults, + SearchOptions, + TextSearch, + TextSearchResult, + TSearchResult, +) +from semantic_kernel.exceptions import ServiceInitializationError, ServiceInvalidRequestError +from semantic_kernel.kernel_pydantic import KernelBaseModel, KernelBaseSettings +from semantic_kernel.kernel_types import OptionalOneOrList +from semantic_kernel.utils.feature_stage_decorator import experimental +from semantic_kernel.utils.telemetry.user_agent import SEMANTIC_KERNEL_USER_AGENT + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +logger: logging.Logger = logging.getLogger(__name__) + +# region Constants +DEFAULT_URL: Final[str] = "https://api.keenable.ai/v1/search" +DEFAULT_PUBLIC_URL: Final[str] = "https://api.keenable.ai/v1/search/public" +APP_TITLE: Final[str] = "semantic-kernel" +MAX_RESULTS: Final[int] = 50 +QUERY_PARAMETERS: Final[list[str]] = [ + "site", + "published_after", + "published_before", +] + + +# endregion Constants + + +# region KeenableSettings +class KeenableSettings(KernelBaseSettings): + """Keenable Connector settings. + + The settings are first loaded from environment variables with the prefix 'KEENABLE_'. If the + environment variables are not found, the settings can be loaded from a .env file with the + encoding 'utf-8'. All settings are optional: without an API key the connector calls the public + endpoint, which is rate limited per IP. + + Optional settings for prefix 'KEENABLE_' are: + - api_key: SecretStr - The Keenable API key (Env var KEENABLE_API_KEY). Lifts the rate limits. + + """ + + env_prefix: ClassVar[str] = "KEENABLE_" + + api_key: SecretStr | None = None + + +# endregion KeenableSettings + + +# region KeenableWeb +@experimental +class KeenableWebPage(KernelBaseModel): + """A Keenable search result.""" + + title: str | None = None + url: str | None = None + snippet: str | None = None + description: str | None = None + published_at: str | None = None + acquired_at: str | None = None + + @property + def text(self) -> str: + """The page text: the snippet, falling back to the description.""" + return self.snippet or self.description or "" + + +@experimental +class KeenableSearchResponse(KernelBaseModel): + """The response from a Keenable search.""" + + query: str | None = None + results: list[KeenableWebPage] = Field(default_factory=list) + + +# endregion KeenableWeb + + +@experimental +class KeenableSearch(KernelBaseModel, TextSearch): + """A search engine connector that uses the Keenable Search API to perform a web search. + + The connector works without an API key: it then calls the public endpoint, which is + rate limited per IP. Setting KEENABLE_API_KEY switches to the authenticated endpoint + and lifts those limits. + """ + + settings: KeenableSettings + + def __init__( + self, + api_key: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initializes a new instance of the Keenable Search class. + + Args: + api_key: The Keenable API key. Optional; if provided, will override + the value in the env vars or .env file. Without a key the public + endpoint is used. + env_file_path: The optional path to the .env file. If provided, + the settings are read from this file path location. + env_file_encoding: The optional encoding of the .env file. If provided, + the settings are read from this file path location. + """ + try: + settings = KeenableSettings( + api_key=api_key, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + except ValidationError as ex: + raise ServiceInitializationError("Failed to create Keenable settings.") from ex + + super().__init__(settings=settings) # type: ignore[call-arg] + + @override + async def search( + self, + query: str, + output_type: type[str] | type[TSearchResult] | Literal["Any"] = str, + *, + filter: OptionalOneOrList[Callable | str] = None, + skip: int = 0, + top: int = 5, + include_total_count: bool = False, + **kwargs: Any, + ) -> "KernelSearchResults[TSearchResult]": + options = SearchOptions(filter=filter, skip=skip, top=top, include_total_count=include_total_count, **kwargs) + results = await self._inner_search(query=query, options=options) + return KernelSearchResults( + results=self._get_result_strings(results) + if output_type is str + else self._get_text_search_results(results) + if output_type is TextSearchResult + else self._get_keenable_web_pages(results), + total_count=self._get_total_count(results, options), + metadata=self._get_metadata(results), + ) + + async def _get_result_strings(self, response: KeenableSearchResponse) -> AsyncIterable[str]: + for web_page in response.results: + yield web_page.text + + async def _get_text_search_results(self, response: KeenableSearchResponse) -> AsyncIterable[TextSearchResult]: + for web_page in response.results: + yield TextSearchResult( + name=web_page.title, + value=web_page.text, + link=web_page.url, + ) + + async def _get_keenable_web_pages(self, response: KeenableSearchResponse) -> AsyncIterable[KeenableWebPage]: + for val in response.results: + yield val + + def _get_metadata(self, response: KeenableSearchResponse) -> dict[str, Any]: + return {"query": response.query} + + def _get_total_count(self, response: KeenableSearchResponse, options: SearchOptions) -> int | None: + if options.include_total_count: + return len(response.results) + return None + + def _get_options(self, **kwargs: Any) -> SearchOptions: + try: + return SearchOptions(**kwargs) + except ValidationError: + return SearchOptions() + + async def _inner_search(self, query: str, options: SearchOptions) -> KeenableSearchResponse: + self._validate_options(options) + + logger.info( + f"Received request for keenable web search with \ + params:\nnum_results: {options.top}\noffset: {options.skip}" + ) + + url = self._get_url() + headers = self._get_headers() + body = self._build_request_body(query, options) + + logger.info(f"Sending POST request to {url}") + + try: + async with AsyncClient(timeout=10) as client: + response = await client.post(url, headers=headers, json=body) + response.raise_for_status() + parsed = KeenableSearchResponse.model_validate_json(response.text) + except HTTPStatusError as ex: + logger.error(f"Failed to get search results: {ex}") + if ex.response.status_code == 429: + hint = "" if self._get_api_key() else " Set KEENABLE_API_KEY to lift the limits of the public endpoint." + raise ServiceInvalidRequestError(f"Keenable rate limit reached.{hint}") from ex + raise ServiceInvalidRequestError("Failed to get search results.") from ex + except RequestError as ex: + logger.error(f"Client error occurred: {ex}") + raise ServiceInvalidRequestError("A client error occurred while getting search results.") from ex + except Exception as ex: + logger.error(f"An unexpected error occurred: {ex}") + raise ServiceInvalidRequestError("An unexpected error occurred while getting search results.") from ex + + # The API has no offset parameter, so `skip` is applied here: the request asks + # for `top + skip` results and the first `skip` are dropped. + if options.skip: + parsed.results = parsed.results[options.skip :] + return parsed + + def _validate_options(self, options: SearchOptions) -> None: + if options.top <= 0: + raise ServiceInvalidRequestError("top value must be greater than 0.") + if options.skip < 0: + raise ServiceInvalidRequestError("skip must be greater than or equal to 0.") + if options.top + options.skip > MAX_RESULTS: + raise ServiceInvalidRequestError(f"top plus skip must not exceed {MAX_RESULTS}.") + + def _get_api_key(self) -> str | None: + if self.settings.api_key is None: + return None + return self.settings.api_key.get_secret_value() or None + + def _get_url(self) -> str: + return DEFAULT_URL if self._get_api_key() else DEFAULT_PUBLIC_URL + + def _get_headers(self) -> dict[str, str]: + # X-Keenable-Title identifies the calling application; the public endpoint requires it. + headers = {"X-Keenable-Title": APP_TITLE, "User-Agent": SEMANTIC_KERNEL_USER_AGENT} + api_key = self._get_api_key() + if api_key: + headers["X-API-Key"] = api_key + return headers + + def _parse_filter_lambda(self, filter_lambda: Callable | str) -> list[dict[str, str]]: + """Parse a string lambda or string expression into a list of {field: value} dicts using AST.""" + expr = filter_lambda if isinstance(filter_lambda, str) else getsource(filter_lambda).strip() + tree = ast.parse(expr, mode="eval") + node = tree.body + visitor = SearchLambdaVisitor(valid_parameters=QUERY_PARAMETERS) + visitor.visit(node) + return visitor.filters + + def _build_request_body(self, query: str, options: SearchOptions) -> dict[str, str | int]: + body: dict[str, str | int] = { + "query": query or "", + "max_results": options.top + options.skip, + } + if not options.filter: + return body + filters = options.filter + if not isinstance(filters, list): + filters = [filters] + for f in filters: + try: + for d in self._parse_filter_lambda(f): + for field, value in d.items(): + # SearchLambdaVisitor URL-encodes values for query strings; + # this API takes a JSON body, so decode them again. + body[field] = unquote_plus(value) + except Exception as exc: + logger.warning(f"Failed to parse filter lambda: {f}, ignoring this filter. Error: {exc}") + continue + return body diff --git a/python/semantic_kernel/connectors/search.py b/python/semantic_kernel/connectors/search.py index 25ac66dcd64b..2e1a23868eb1 100644 --- a/python/semantic_kernel/connectors/search.py +++ b/python/semantic_kernel/connectors/search.py @@ -3,23 +3,27 @@ import importlib _IMPORTS = { - "GoogleSearch": ".google", - "GoogleSearchSettings": ".google", - "GoogleSearchResult": ".google", - "GoogleSearchResponse": ".google", - "GoogleSearchInformation": ".google", + "GoogleSearch": ".google_search", + "GoogleSearchSettings": ".google_search", + "GoogleSearchResult": ".google_search", + "GoogleSearchResponse": ".google_search", + "GoogleSearchInformation": ".google_search", "BraveSearch": ".brave", "BraveSettings": ".brave", "BraveWebPages": ".brave", "BraveWebPage": ".brave", "BraveSearchResponse": ".brave", + "KeenableSearch": ".keenable", + "KeenableSettings": ".keenable", + "KeenableWebPage": ".keenable", + "KeenableSearchResponse": ".keenable", } def __getattr__(name: str): if name in _IMPORTS: submod_name = _IMPORTS[name] - module = importlib.import_module(submod_name, package=__name__) + module = importlib.import_module(submod_name, package=__name__.rsplit(".", 1)[0]) return getattr(module, name) raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/python/semantic_kernel/connectors/search.pyi b/python/semantic_kernel/connectors/search.pyi index 167cf21789e1..68fdf0e5a161 100644 --- a/python/semantic_kernel/connectors/search.pyi +++ b/python/semantic_kernel/connectors/search.pyi @@ -8,6 +8,7 @@ from .google_search import ( GoogleSearchResult, GoogleSearchSettings, ) +from .keenable import KeenableSearch, KeenableSearchResponse, KeenableSettings, KeenableWebPage __all__ = [ "BraveSearch", @@ -20,4 +21,8 @@ __all__ = [ "GoogleSearchResponse", "GoogleSearchResult", "GoogleSearchSettings", + "KeenableSearch", + "KeenableSearchResponse", + "KeenableSettings", + "KeenableWebPage", ] diff --git a/python/tests/unit/connectors/conftest.py b/python/tests/unit/connectors/conftest.py index bd9111a70c55..e844fd2511d1 100644 --- a/python/tests/unit/connectors/conftest.py +++ b/python/tests/unit/connectors/conftest.py @@ -125,6 +125,28 @@ def brave_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): return env_vars +@fixture() +def keenable_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): + """Fixture to set environment variables for KeenableConnector.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = {"KEENABLE_API_KEY": "test_api_key"} + + env_vars.update(override_env_param_dict) + + for key, value in env_vars.items(): + if key not in exclude_list: + monkeypatch.setenv(key, value) + else: + monkeypatch.delenv(key, raising=False) + + return env_vars + + @fixture() def google_search_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): """Fixture to set environment variables for the Google Search Connector.""" diff --git a/python/tests/unit/connectors/search/test_keenable_search.py b/python/tests/unit/connectors/search/test_keenable_search.py new file mode 100644 index 000000000000..d68b6d0d7e42 --- /dev/null +++ b/python/tests/unit/connectors/search/test_keenable_search.py @@ -0,0 +1,299 @@ +# Copyright (c) Microsoft. All rights reserved. + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from semantic_kernel.connectors.keenable import ( + APP_TITLE, + DEFAULT_PUBLIC_URL, + DEFAULT_URL, + KeenableSearch, + KeenableWebPage, +) +from semantic_kernel.data.text_search import KernelSearchResults, TextSearchResult +from semantic_kernel.exceptions import ServiceInvalidRequestError + +RESPONSE_JSON = json.dumps({ + "query": "Test query", + "results": [ + { + "title": "First", + "url": "https://example.com/first", + "snippet": "First snippet", + "description": "First description", + }, + { + "title": "Second", + "url": "https://example.com/second", + "snippet": "", + "description": "Second description", + }, + { + "title": "Third", + "url": "https://example.com/third", + "snippet": "Third snippet", + "description": "", + "acquired_at": "2025-01-01T00:00:00Z", + }, + ], +}) + + +@pytest.fixture +def keenable_search(keenable_unit_test_env): + """Set up the fixture to configure the Keenable Search for these tests.""" + return KeenableSearch() + + +@pytest.fixture +def keenable_search_keyless(monkeypatch): + """A Keenable Search with no API key anywhere.""" + monkeypatch.delenv("KEENABLE_API_KEY", raising=False) + return KeenableSearch(env_file_path="nonexistent.env") + + +@pytest.fixture +def async_client_mock(): + """Set up the fixture to mock AsyncClient.""" + async_client_mock = AsyncMock() + with patch("semantic_kernel.connectors.keenable.AsyncClient.__aenter__", return_value=async_client_mock): + yield async_client_mock + + +@pytest.fixture +def mock_response(async_client_mock): + """Make the mocked client return a canned three-result response.""" + mock_result = MagicMock() + mock_result.text = RESPONSE_JSON + async_client_mock.post.return_value = mock_result + return mock_result + + +async def test_keenable_search_init_with_key(keenable_search): + """Test that KeenableSearch picks up the key from the env.""" + assert keenable_search.settings.api_key.get_secret_value() == "test_api_key" + + +@pytest.mark.parametrize("exclude_list", [["KEENABLE_API_KEY"]], indirect=True) +async def test_keenable_search_init_without_key(keenable_unit_test_env): + """Test that KeenableSearch initializes without a key; the key is optional.""" + search_instance = KeenableSearch(env_file_path="nonexistent.env") + assert search_instance.settings.api_key is None + + +async def test_keyless_request_uses_public_endpoint(keenable_search_keyless, async_client_mock, mock_response): + """Without a key: public endpoint, title header, no key header.""" + await keenable_search_keyless.search("Test query") + + call = async_client_mock.post.call_args + assert call.args[0] == DEFAULT_PUBLIC_URL + headers = call.kwargs["headers"] + assert headers["X-Keenable-Title"] == APP_TITLE + assert "X-API-Key" not in headers + assert call.kwargs["json"] == {"query": "Test query", "max_results": 5} + + +async def test_empty_key_is_keyless(monkeypatch, async_client_mock, mock_response): + """An empty KEENABLE_API_KEY behaves like no key.""" + monkeypatch.setenv("KEENABLE_API_KEY", "") + await KeenableSearch().search("Test query") + + call = async_client_mock.post.call_args + assert call.args[0] == DEFAULT_PUBLIC_URL + assert "X-API-Key" not in call.kwargs["headers"] + + +async def test_keyed_request_uses_authenticated_endpoint(keenable_search, async_client_mock, mock_response): + """With a key: authenticated endpoint, key header, title header still sent.""" + await keenable_search.search("Test query", top=3) + + call = async_client_mock.post.call_args + assert call.args[0] == DEFAULT_URL + headers = call.kwargs["headers"] + assert headers["X-API-Key"] == "test_api_key" + assert headers["X-Keenable-Title"] == APP_TITLE + assert call.kwargs["json"] == {"query": "Test query", "max_results": 3} + + +async def test_search_success(keenable_search, mock_response): + """Test that search returns strings, using the description when the snippet is empty.""" + kernel_results: KernelSearchResults[str] = await keenable_search.search("Test query", include_total_count=True) + + results_list = [res async for res in kernel_results.results] + + assert results_list == ["First snippet", "Second description", "Third snippet"] + assert kernel_results.total_count == 3 + assert kernel_results.metadata == {"query": "Test query"} + + +async def test_get_text_search_results_success(keenable_search, mock_response): + """Test that search returns KernelSearchResults[TextSearchResult] with the right mapping.""" + kernel_results: KernelSearchResults[TextSearchResult] = await keenable_search.search( + "Test query", include_total_count=True, output_type=TextSearchResult + ) + + results_list = [res async for res in kernel_results.results] + + assert len(results_list) == 3 + assert all(isinstance(res, TextSearchResult) for res in results_list) + assert results_list[0].name == "First" + assert results_list[0].value == "First snippet" + assert results_list[0].link == "https://example.com/first" + assert results_list[1].value == "Second description" + assert kernel_results.total_count == 3 + + +async def test_get_search_results_success(keenable_search, mock_response): + """Test that search with output_type="Any" returns KernelSearchResults[KeenableWebPage].""" + kernel_results = await keenable_search.search("Test query", include_total_count=True, output_type="Any") + + results_list = [res async for res in kernel_results.results] + + assert len(results_list) == 3 + assert all(isinstance(res, KeenableWebPage) for res in results_list) + assert results_list[2].title == "Third" + assert results_list[2].acquired_at == "2025-01-01T00:00:00Z" + assert kernel_results.total_count == 3 + + +async def test_skip_is_applied_client_side(keenable_search, async_client_mock, mock_response): + """Test that skip asks for top + skip results and drops the first skip.""" + kernel_results = await keenable_search.search("Test query", top=2, skip=1, include_total_count=True) + + assert async_client_mock.post.call_args.kwargs["json"]["max_results"] == 3 + results_list = [res async for res in kernel_results.results] + assert results_list == ["Second description", "Third snippet"] + assert kernel_results.total_count == 2 + + +async def test_search_http_status_error(keenable_search, async_client_mock): + """Test that search raises ServiceInvalidRequestError on HTTPStatusError.""" + mock_result = MagicMock() + mock_result.raise_for_status.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=MagicMock(status_code=500) + ) + async_client_mock.post.return_value = mock_result + + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search.search("Test query") + assert "Failed to get search results." in str(exc_info.value) + + +async def test_search_rate_limited_keyless(keenable_search_keyless, async_client_mock): + """Test that a 429 raises, with a hint to set the key when running keyless.""" + mock_result = MagicMock() + mock_result.raise_for_status.side_effect = httpx.HTTPStatusError( + "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) + ) + async_client_mock.post.return_value = mock_result + + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search_keyless.search("Test query") + assert "rate limit" in str(exc_info.value) + assert "KEENABLE_API_KEY" in str(exc_info.value) + + +async def test_search_rate_limited_keyed(keenable_search, async_client_mock): + """Test that a 429 with a key raises without the keyless hint.""" + mock_result = MagicMock() + mock_result.raise_for_status.side_effect = httpx.HTTPStatusError( + "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) + ) + async_client_mock.post.return_value = mock_result + + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search.search("Test query") + assert "rate limit" in str(exc_info.value) + assert "KEENABLE_API_KEY" not in str(exc_info.value) + + +async def test_search_request_error(keenable_search, async_client_mock): + """Test that search raises ServiceInvalidRequestError on RequestError.""" + async_client_mock.post.side_effect = httpx.RequestError("Client error") + + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search.search("Test query") + assert "A client error occurred while getting search results." in str(exc_info.value) + + +async def test_search_generic_exception(keenable_search, async_client_mock): + """Test that search raises ServiceInvalidRequestError on an unexpected exception.""" + async_client_mock.post.side_effect = Exception("Something unexpected") + + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search.search("Test query") + assert "An unexpected error occurred while getting search results." in str(exc_info.value) + + +async def test_validate_options_raises_error_for_large_top(keenable_search): + """Test that _validate_options raises when top + skip exceeds the API maximum.""" + with pytest.raises(ServiceInvalidRequestError) as exc_info: + await keenable_search.search("test", top=50, skip=1) + assert "top plus skip must not exceed 50." in str(exc_info.value) + + +async def test_search_no_filter(keenable_search, async_client_mock, mock_response): + """Test that search sends only query and max_results when no filter is provided.""" + await keenable_search.search("test query") + + assert async_client_mock.post.call_args.kwargs["json"] == {"query": "test query", "max_results": 5} + + +async def test_search_with_filters(keenable_search, async_client_mock, mock_response): + """Test that site and date filters end up in the request body, decoded.""" + await keenable_search.search( + "test query", + filter="lambda x: x.site == 'learn.microsoft.com' and x.published_after == '2025-01-01'", + ) + + body = async_client_mock.post.call_args.kwargs["json"] + assert body["site"] == "learn.microsoft.com" + assert body["published_after"] == "2025-01-01" + + +async def test_search_ignores_invalid_filter(keenable_search, async_client_mock, mock_response): + """Test that an unsupported filter is ignored rather than failing the search.""" + await keenable_search.search("test query", filter="lambda x: x.country == 'US'") + + body = async_client_mock.post.call_args.kwargs["json"] + assert body == {"query": "test query", "max_results": 5} + + +@pytest.mark.parametrize( + "filter_lambda,expected", + [ + ("lambda x: x.site == 'example.com'", [{"site": "example.com"}]), + ("lambda x: x.published_after == '2025-01-01'", [{"published_after": "2025-01-01"}]), + ("lambda x: x.published_before == '2025-12-31'", [{"published_before": "2025-12-31"}]), + ( + "lambda x: x.site == 'example.com' and x.published_after == '2025-01-01'", + [{"site": "example.com"}, {"published_after": "2025-01-01"}], + ), + ( + lambda x: x.site == "example.com" and x.published_after == "2025-01-01", + [{"site": "example.com"}, {"published_after": "2025-01-01"}], + ), + ], +) +def test_parse_filter_lambda_valid(keenable_search, filter_lambda, expected): + assert keenable_search._parse_filter_lambda(filter_lambda) == expected + + +@pytest.mark.parametrize( + "filter_lambda,exception_type", + [ + ("lambda x: x.site != 'example.com'", NotImplementedError), + ("lambda x: x.site == y", NotImplementedError), + ("lambda x: x.site == None", NotImplementedError), + ("lambda x: x.published_after > '2025-01-01'", NotImplementedError), + ("lambda x: x.unknown == 'foo'", ValueError), + ("lambda x: x.site == 'a.com' or x.site == 'b.com'", NotImplementedError), + ("lambda x: x.country == 'US'", ValueError), # not in Keenable QUERY_PARAMETERS + ], +) +def test_parse_filter_lambda_invalid(keenable_search, filter_lambda, exception_type): + with pytest.raises(exception_type): + keenable_search._parse_filter_lambda(filter_lambda) diff --git a/python/tests/unit/connectors/search/test_search_facade.py b/python/tests/unit/connectors/search/test_search_facade.py new file mode 100644 index 000000000000..ccc5b72b33cc --- /dev/null +++ b/python/tests/unit/connectors/search/test_search_facade.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from semantic_kernel.connectors import brave, google_search, keenable, search + + +def test_facade_imports_keenable_search(): + """Test that the lazy facade resolves KeenableSearch to the real module.""" + from semantic_kernel.connectors.search import KeenableSearch, KeenableSettings + + assert KeenableSearch is keenable.KeenableSearch + assert KeenableSettings is keenable.KeenableSettings + + +def test_facade_imports_brave_and_google_search(): + """Test that the lazy facade resolves the Brave and Google connectors too.""" + from semantic_kernel.connectors.search import BraveSearch, GoogleSearch + + assert BraveSearch is brave.BraveSearch + assert GoogleSearch is google_search.GoogleSearch + + +def test_facade_dir_lists_exports(): + """Test that dir() on the facade lists the exported names.""" + names = dir(search) + assert "KeenableSearch" in names + assert "BraveSearch" in names + assert "GoogleSearch" in names + + +def test_facade_unknown_attribute_raises(): + """Test that an unknown name raises AttributeError, not an import error.""" + with pytest.raises(AttributeError): + search.NotAConnector # noqa: B018