diff --git a/src/poetry.lock b/src/poetry.lock index e9ddd086..7de21432 100644 --- a/src/poetry.lock +++ b/src/poetry.lock @@ -6,7 +6,7 @@ version = "2.7.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, @@ -18,7 +18,7 @@ version = "3.14.2" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiohttp-3.14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ceb77c159b2b4c1a179b96a26af36bcaa68eb79c393ec4f569386a69d013cbe9"}, {file = "aiohttp-3.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3f3381f81bc1c6cbe160b2a3708d39d05014329118e6b648b95edc841eeeebd4"}, @@ -161,7 +161,7 @@ version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -265,7 +265,7 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" -groups = ["main", "optional"] +groups = ["optional"] markers = "python_version == \"3.10\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, @@ -1320,7 +1320,7 @@ version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, @@ -2226,75 +2226,6 @@ numpy = [ {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, ] -[[package]] -name = "langchain-classic" -version = "1.0.8" -description = "Building applications with LLMs through composability" -optional = false -python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] -files = [ - {file = "langchain_classic-1.0.8-py3-none-any.whl", hash = "sha256:1a11ea7fbe630c4f2af2f3873d27718ceac9488cf32d0821030be7cf039a6213"}, - {file = "langchain_classic-1.0.8.tar.gz", hash = "sha256:ada0cc341a8a5b80fb24d73bdfaaeb849056ee2d8a41cc468355163fd3667484"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} -langchain-core = ">=1.4.4,<2.0.0" -langchain-text-splitters = ">=1.1.2,<2.0.0" -langsmith = ">=0.1.17,<1.0.0" -pydantic = ">=2.7.4,<3.0.0" -pyyaml = ">=5.3.0,<7.0.0" -requests = ">=2.0.0,<3.0.0" -sqlalchemy = ">=1.4.0,<3.0.0" - -[package.extras] -anthropic = ["langchain-anthropic"] -aws = ["langchain-aws"] -azure-ai = ["langchain-azure-ai"] -cohere = ["langchain-cohere"] -community = ["langchain-community"] -deepseek = ["langchain-deepseek"] -fireworks = ["langchain-fireworks"] -google-genai = ["langchain-google-genai"] -google-vertexai = ["langchain-google-vertexai"] -groq = ["langchain-groq"] -huggingface = ["langchain-huggingface"] -mistralai = ["langchain-mistralai"] -ollama = ["langchain-ollama"] -openai = ["langchain-openai"] -perplexity = ["langchain-perplexity"] -together = ["langchain-together"] -xai = ["langchain-xai"] - -[[package]] -name = "langchain-community" -version = "0.4.2" -description = "Community contributed LangChain integrations." -optional = false -python-versions = "<4.0.0,>=3.10.0" -groups = ["main"] -files = [ - {file = "langchain_community-0.4.2-py3-none-any.whl", hash = "sha256:84dd8c5122532394d5b6849a5fc9995ef28e4f77227daeb09f24b3d942e9e466"}, - {file = "langchain_community-0.4.2.tar.gz", hash = "sha256:a99308160d53d7e9b5965ee665e5173709914338210089fd5788ad724432c21e"}, -] - -[package.dependencies] -aiohttp = ">=3.8.3,<4.0.0" -httpx-sse = ">=0.4.0,<1.0.0" -langchain-classic = ">=1.0.7,<2.0.0" -langchain-core = ">=1.4.0,<2.0.0" -langsmith = ">=0.1.125,<1.0.0" -numpy = [ - {version = ">=1.26.2", markers = "python_version < \"3.13\""}, - {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, -] -pydantic-settings = ">=2.10.1,<3.0.0" -pyyaml = ">=5.3.0,<7.0.0" -requests = ">=2.32.5,<3.0.0" -sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" - [[package]] name = "langchain-core" version = "1.5.2" @@ -2859,7 +2790,7 @@ version = "6.7.1" description = "multidict implementation" optional = false python-versions = ">=3.9" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, @@ -3733,7 +3664,7 @@ version = "0.5.2" description = "Accelerated property cache" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, @@ -6961,7 +6892,7 @@ version = "1.24.5" description = "Yet another URL library" optional = false python-versions = ">=3.10" -groups = ["main", "optional"] +groups = ["optional"] files = [ {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"}, {file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"}, @@ -7210,4 +7141,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "<3.14,>=3.10" -content-hash = "79a448728a85c44641ad9b79bfee561a6e8c744ef2a3ce41078613f33a69508b" +content-hash = "d68d58760b3388c028307609e58ce3b4034f1150c109fb9e5ec0d28269402d9d" diff --git a/src/pyproject.toml b/src/pyproject.toml index 3191ece0..bba1b7a2 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -22,7 +22,6 @@ optional = true [tool.poetry.dependencies] python = "<3.14,>=3.10" langchain-core = ">=1.5.0,<2.0" -langchain-community = ">=0.4.2,<0.5" langchain-text-splitters = ">=1.1.2,<2.0" python-dotenv = "^1.0.0" langchain-openai = ">=1.3.5,<2.0" @@ -53,6 +52,11 @@ nltk = "^3.9.1" # optional even though nothing in sherpa_ai/ imports it directly. It was # previously pulled in transitively by `unstructured`. transformers = "^5.14.1" +# database/user_usage_tracker.py imports sqlalchemy directly for the usage- +# tracking DB. It was previously only a transitive dependency of +# langchain-community (via langchain-classic); dropping that package +# exposed this as a missing direct dependency. +sqlalchemy = "^2.0.51" [tool.poetry.group.test.dependencies] pytest = "^9.0.3" diff --git a/src/sherpa_ai/tools.py b/src/sherpa_ai/tools.py index 51a2a5e3..dcc60b73 100644 --- a/src/sherpa_ai/tools.py +++ b/src/sherpa_ai/tools.py @@ -3,10 +3,10 @@ from typing import Any, List, Tuple, Union import requests -from langchain_community.utilities import GoogleSerperAPIWrapper from langchain_core.tools import BaseTool from langchain_core.vectorstores import VectorStoreRetriever from loguru import logger +from pydantic import BaseModel, Field from typing_extensions import Literal import sherpa_ai.config as cfg @@ -19,6 +19,34 @@ HTTP_GET_TIMEOUT = 20.0 +class GoogleSerperAPIWrapper(BaseModel): + """Wrapper around the Serper.dev Google Search API. + + Replaces langchain_community's GoogleSerperAPIWrapper, which was the + package's only remaining use of langchain-community (a project that + now prints its own "being sunset, no longer actively maintained" + deprecation warning on import). + """ + + url: str = "https://google.serper.dev/search" + api_key: str = Field(default_factory=lambda: cfg.SERPER_API_KEY or "") + + def search(self, query: str) -> dict: + """Query the Serper.dev Google Search API directly.""" + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + response = requests.post( + self.url, + headers=headers, + params={"q": query}, + timeout=HTTP_GET_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + def get_tools(memory, config): """Factory function to create and configure a set of tools for the agent. @@ -328,8 +356,7 @@ def _run_single_query( Link: https://example.com/python """ logger.debug(f"Search query: {query}") - google_serper = GoogleSerperAPIWrapper() - search_results = google_serper._google_serper_api_results(query) + search_results = GoogleSerperAPIWrapper().search(query) logger.debug(f"Google Search Result: {search_results}") # case 1: answerBox in the result dictionary diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 91470649..a04a8ed6 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -46,7 +46,7 @@ def mock_env(external_api): os.environ["OPENAI_API_KEY"] = "dummy" with mock.patch( - "langchain_community.utilities.GoogleSerperAPIWrapper._google_serper_api_results" + "sherpa_ai.tools.GoogleSerperAPIWrapper.search" ) as mock_search, mock.patch("sherpa_ai.utils.scrape_with_url") as mock_scrape: mock_search.return_value = GOOGLE_SEARCH_MOCK # mock_socket.side_effect = guard diff --git a/src/tests/unit_tests/output_parsers/test_citation_validation.py b/src/tests/unit_tests/output_parsers/test_citation_validation.py index 733cd5f2..23e4b507 100644 --- a/src/tests/unit_tests/output_parsers/test_citation_validation.py +++ b/src/tests/unit_tests/output_parsers/test_citation_validation.py @@ -77,7 +77,7 @@ def test_task_agent_succeeds(get_llm, external_api): # noqa: F811 } with mock.patch( - "langchain_community.utilities.GoogleSerperAPIWrapper._google_serper_api_results" + "sherpa_ai.tools.GoogleSerperAPIWrapper.search" ) as mock_search: mock_search.return_value = GOOGLE_SEARCH_MOCK result = task_agent.run() diff --git a/src/tests/unit_tests/tools/test_search_tool.py b/src/tests/unit_tests/tools/test_search_tool.py index 194d4c1f..facaacb6 100644 --- a/src/tests/unit_tests/tools/test_search_tool.py +++ b/src/tests/unit_tests/tools/test_search_tool.py @@ -1,11 +1,16 @@ import re -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from loguru import logger +import sherpa_ai.config as cfg from sherpa_ai.config import AgentConfig -from sherpa_ai.tools import SearchTool +from sherpa_ai.tools import GoogleSerperAPIWrapper, SearchTool + +# Captured at collection time, before the autouse `mock_env` fixture (conftest.py) +# patches GoogleSerperAPIWrapper.search for every test. +_real_search = GoogleSerperAPIWrapper.search def _extract_links(search_result: str) -> list: @@ -16,6 +21,27 @@ def _extract_links(search_result: str) -> list: return re.findall(r"Link:(\S+)", search_result) +def test_google_serper_search_calls_serper_api_directly(): + """Regression test for the langchain-community removal: this hits + Serper's API directly (no GoogleSerperAPIWrapper in between), so pin + down the request shape it relies on.""" + mock_response = MagicMock() + mock_response.json.return_value = {"organic": []} + + with patch.object(cfg, "SERPER_API_KEY", "test-key"), \ + patch("sherpa_ai.tools.requests.post", return_value=mock_response) as mock_post: + result = _real_search(GoogleSerperAPIWrapper(), "what is the weather today?") + + mock_post.assert_called_once_with( + "https://google.serper.dev/search", + headers={"X-API-KEY": "test-key", "Content-Type": "application/json"}, + params={"q": "what is the weather today?"}, + timeout=pytest.approx(20.0), + ) + mock_response.raise_for_status.assert_called_once() + assert result == {"organic": []} + + def test_formulate_search_query(): config = AgentConfig(verbose=True) search_tool = SearchTool(config=config)