diff --git a/AGENTS.md b/AGENTS.md index d9e4b7ca..ac320a4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ Each example is self-contained and demonstrates specific patterns and capabiliti - `hackernews_search/` - Hacker News search via the free Algolia API - `pdf_reader/` - PDF text + metadata extraction (local file or URL, page-range support) - `searxng_search/` - Privacy-focused search integration +- `serply_search/` - Google web, news, and scholar search via the Serply API - `tavily_search/` - Tavily API search tool - `weather/` - Current conditions and daily/hourly forecast via Open-Meteo (no key) - `webpage_scraper/` - Web scraping capabilities diff --git a/README.md b/README.md index 242f846a..270f5588 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,7 @@ Atomic Forge is a collection of tools that can be used with Atomic Agents to ext - Hacker News Search - PDF Reader - SearXNG Search +- Serply Search - Tavily Search - Webpage Scraper - Weather diff --git a/atomic-forge/README.md b/atomic-forge/README.md index bf05ca54..d34c5a0f 100644 --- a/atomic-forge/README.md +++ b/atomic-forge/README.md @@ -21,6 +21,7 @@ The Atomic Forge project includes the following tools: - [Hacker News Search](/atomic-forge/tools/hackernews_search/README.md) — search HN stories, comments, Show HN, Ask HN via the free Algolia API. - [PDF Reader](/atomic-forge/tools/pdf_reader/README.md) — extract text and metadata from a local or remote PDF, with page-range filtering. - [SearXNG Search](/atomic-forge/tools/searxng_search/README.md) +- [Serply Search](/atomic-forge/tools/serply_search/README.md) - [Tavily Search](/atomic-forge/tools/tavily_search/README.md) - [Webpage Scraper](/atomic-forge/tools/webpage_scraper/README.md) - [Weather](/atomic-forge/tools/weather/README.md) — current conditions and forecast via the free Open-Meteo API. diff --git a/atomic-forge/tools/serply_search/.coveragerc b/atomic-forge/tools/serply_search/.coveragerc new file mode 100644 index 00000000..c45136ef --- /dev/null +++ b/atomic-forge/tools/serply_search/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = tool +omit = */tests/* + +[report] +exclude_lines = + if __name__ == "__main__": +show_missing = True diff --git a/atomic-forge/tools/serply_search/README.md b/atomic-forge/tools/serply_search/README.md new file mode 100644 index 00000000..a3679694 --- /dev/null +++ b/atomic-forge/tools/serply_search/README.md @@ -0,0 +1,59 @@ +# Serply Search Tool + +## Overview +Searches Google web results, Google News, or Google Scholar through the [Serply](https://serply.io) API and returns each result's title, URL, and snippet, plus the publish date and source for news and the authors and citation count for papers. Requires a Serply API key. + +## Prerequisites and Dependencies +- Python 3.12 or later +- `atomic-agents` +- `pydantic` +- `aiohttp` +- A Serply API key from [serply.io](https://serply.io) + +## Installation +1. Use the Atomic Assembler CLI: run `atomic` and pick `serply_search`. +2. Or copy the `tool/` folder directly into your project. + +## Configuration +- `api_key` (str): Serply API key. Falls back to the `SERPLY_API_KEY` environment variable when empty. +- `base_url` (str): API base URL (default `https://api.serply.io/v1`). +- `hl` (str): interface language code, e.g. `en` (default `en`). +- `gl` (str): country code for the search, e.g. `us` (default `us`). +- `user_agent` (str): user agent sent with each request. +- `timeout` (float): HTTP timeout in seconds (default 30). + +## Input & Output Structure + +### Input Schema +- `queries` (list[str]): search queries to run. +- `search_type` (str): `search` (Google web), `news` (Google News), or `scholar` (Google Scholar). Default `search`. +- `max_results_per_query` (int): 1-100 (default 10). + +### Output Schema +A list of `SerplySearchResultItem` items. Each has `query`, `title`, `url`, and optional `description`, `position` (web search), `published` and `source` (news), `authors` and `citations` (scholar). + +## Usage + +```python +from tool.serply_search import SerplySearchTool, SerplySearchToolConfig, SerplySearchToolInputSchema + +tool = SerplySearchTool(config=SerplySearchToolConfig(api_key="your-serply-api-key")) + +output = tool.run(SerplySearchToolInputSchema( + queries=["retrieval augmented generation"], + search_type="scholar", + max_results_per_query=3, +)) + +for item in output.results: + print(item.title, "-", item.url) + print(item.authors, item.citations) +``` + +The request and response formats for each endpoint are documented at [serply.io/docs](https://serply.io/docs). + +## Contributing +PRs welcome. See the main repo `CONTRIBUTING.md`. + +## License +Same as the main Atomic Agents project. diff --git a/atomic-forge/tools/serply_search/pyproject.toml b/atomic-forge/tools/serply_search/pyproject.toml new file mode 100644 index 00000000..51fd7794 --- /dev/null +++ b/atomic-forge/tools/serply_search/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["tool"] + +[project] +name = "serply-search" +version = "1.0.0" +description = "Google web, news, and scholar search tool for Atomic Agents via the Serply API" +readme = "README.md" +authors = [ + { name = "Serply", email = "googio@serply.io" } +] +requires-python = ">=3.12" +dependencies = [ + "atomic-agents", + "pydantic>=2.10.3,<3.0.0", + "aiohttp>=3.9.0,<4.0.0", +] + +[dependency-groups] +dev = [ + "coverage>=7.6.1,<8.0.0", + "pytest>=8.3.3,<9.0.0", + "pytest-asyncio>=0.23.5,<1.0.0", + "pytest-cov>=5.0.0,<6.0.0", + "python-dotenv>=1.0.0,<2.0.0", + "rich>=13.7.0,<14.0.0", +] + +[tool.uv.sources] +atomic-agents = { workspace = true } diff --git a/atomic-forge/tools/serply_search/requirements.txt b/atomic-forge/tools/serply_search/requirements.txt new file mode 100644 index 00000000..469c7e98 --- /dev/null +++ b/atomic-forge/tools/serply_search/requirements.txt @@ -0,0 +1,3 @@ +atomic-agents>=2.0.0,<3.0.0 +pydantic>=2.10.3,<3.0.0 +aiohttp>=3.9.0,<4.0.0 diff --git a/atomic-forge/tools/serply_search/tests/test_serply_search.py b/atomic-forge/tools/serply_search/tests/test_serply_search.py new file mode 100644 index 00000000..1a3de146 --- /dev/null +++ b/atomic-forge/tools/serply_search/tests/test_serply_search.py @@ -0,0 +1,203 @@ +import os +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from tool.serply_search import ( # noqa: E402 + SerplySearchResultItem, + SerplySearchTool, + SerplySearchToolConfig, + SerplySearchToolInputSchema, + SerplySearchToolOutputSchema, +) + + +SEARCH_HIT = { + "title": "Welcome to Python.org", + "description": "Python is a programming language that lets you work quickly.", + "position": 1, + "realPosition": 1, + "result_type": "organic", + "link": "https://www.python.org/", +} + +NEWS_HIT = { + "title": "Python 3.14 released - example.com", + "link": "https://example.com/python-3-14", + "published": "Wed, 10 Jun 2026 07:00:00 GMT", + "summary": 'Python 3.14 released', + "source": {"href": "https://example.com", "title": "example.com"}, +} + +SCHOLAR_HIT = { + "title": "SciPy 1.0: fundamental algorithms for scientific computing in Python", + "link": "https://example.com/scipy-1-0", + "id": "W3003257820", + "author": {"names": "Pauli Virtanen, Ralf Gommers"}, + "description": "SciPy is an open-source scientific computing library.", + "extras": {"citations": {"count": 12345}}, +} + + +def _mock_session(status: int, payload: dict, reason: str = "OK") -> MagicMock: + session = MagicMock() + response = SimpleNamespace(status=status, reason=reason, json=AsyncMock(return_value=payload)) + session.get.return_value.__aenter__.return_value = response + return session + + +@pytest.fixture +def tool(): + return SerplySearchTool(config=SerplySearchToolConfig(api_key="test-key", hl="de", gl="ch")) + + +def test_to_item_search(tool): + item = SerplySearchTool._to_item(SEARCH_HIT, "python") + assert isinstance(item, SerplySearchResultItem) + assert item.query == "python" + assert item.title == "Welcome to Python.org" + assert item.url == "https://www.python.org/" + assert item.description.startswith("Python is a programming language") + assert item.position == 1 + assert item.published is None + assert item.authors is None + + +def test_to_item_news(tool): + """News entries carry a summary, a publish date, and a source dict.""" + item = SerplySearchTool._to_item(NEWS_HIT, "python") + assert item.description.startswith(" SerplySearchResultItem: + source = hit.get("source") + if isinstance(source, dict): + source = source.get("title") or source.get("href") + + author = hit.get("author") + authors = author.get("names") if isinstance(author, dict) else author + + citations = None + extras = hit.get("extras") + if isinstance(extras, dict) and isinstance(extras.get("citations"), dict): + citations = extras["citations"].get("count") + + return SerplySearchResultItem( + query=query, + title=hit["title"], + url=hit["link"], + description=hit.get("description") or hit.get("summary"), + position=hit.get("position"), + published=hit.get("published"), + source=str(source) if source else None, + authors=str(authors) if authors else None, + citations=citations, + ) + + async def _fetch( + self, + session: aiohttp.ClientSession, + query: str, + search_type: str, + max_results: int, + ) -> List[SerplySearchResultItem]: + params = {"q": query, "num": str(max_results), "hl": self.hl, "gl": self.gl} + + async with session.get(f"{self.base_url}/{search_type}/", params=params) as resp: + if resp.status != 200: + raise Exception(f"Serply {search_type} failed for '{query}': {resp.status} {resp.reason}") + data = await resp.json() + + hits = data.get(self.RESULT_KEYS[search_type], []) + items = [self._to_item(hit, query) for hit in hits if hit.get("title") and hit.get("link")] + return items[:max_results] + + async def run_async(self, params: SerplySearchToolInputSchema) -> SerplySearchToolOutputSchema: + if not self.api_key: + raise ValueError( + "Serply API key is missing. Set SerplySearchToolConfig.api_key or the SERPLY_API_KEY environment variable." + ) + + headers = {"X-Api-Key": self.api_key, "User-Agent": self.user_agent, "Accept": "application/json"} + timeout = aiohttp.ClientTimeout(total=self.timeout) + async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: + tasks = [self._fetch(session, q, params.search_type, params.max_results_per_query) for q in params.queries] + grouped = await asyncio.gather(*tasks, return_exceptions=True) + + results: List[SerplySearchResultItem] = [] + for query, group in zip(params.queries, grouped): + if isinstance(group, Exception): + logger.warning("Serply query '%s' failed: %s", query, group) + continue + results.extend(group) + return SerplySearchToolOutputSchema(results=results) + + def run(self, params: SerplySearchToolInputSchema) -> SerplySearchToolOutputSchema: + with ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, self.run_async(params)).result() + + +################# +# EXAMPLE USAGE # +################# +if __name__ == "__main__": # pragma: no cover + from dotenv import load_dotenv + from rich.console import Console + + load_dotenv() + console = Console() + tool = SerplySearchTool(config=SerplySearchToolConfig(api_key=os.getenv("SERPLY_API_KEY", ""))) + + for search_type in ("search", "news", "scholar"): + output = tool.run( + SerplySearchToolInputSchema( + queries=["atomic agents framework"], + search_type=search_type, + max_results_per_query=3, + ) + ) + console.rule(f"[bold cyan]{search_type}") + for item in output.results: + console.print(f"[bold]{item.title}[/bold]") + console.print(item.url) + if item.description: + console.print(item.description[:200]) + if item.published: + console.print(f"[bold]Published:[/bold] {item.published} [bold]Source:[/bold] {item.source}") + if item.authors: + console.print(f"[bold]Authors:[/bold] {item.authors[:120]} [bold]Citations:[/bold] {item.citations}") + console.print() diff --git a/docs/guides/tools.md b/docs/guides/tools.md index 9b5d9cbe..5d290363 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -153,6 +153,7 @@ The Atomic Forge ships with several pre-built tools: - **Hacker News Search**: Search HN stories, comments, Show HN, Ask HN, polls (free Algolia API) - **PDF Reader**: Extract text and metadata from local or remote PDFs, with page-range filtering - **SearXNG Search**: Search the web using SearXNG +- **Serply Search**: Google web, news, and scholar search via the Serply API - **Tavily Search**: AI-powered web search - **Weather**: Current conditions and daily/hourly forecasts via Open-Meteo (no key required) - **Webpage Scraper**: Extract content from web pages diff --git a/uv.lock b/uv.lock index f17798df..29e756a9 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ members = [ "quickstart", "rag-chatbot", "searxng-search", + "serply-search", "tavily-search", "weather-tool", "web-search-agent", @@ -3929,6 +3930,43 @@ dev = [ { name = "rich", specifier = ">=13.7.0,<14.0.0" }, ] +[[package]] +name = "serply-search" +version = "1.0.0" +source = { editable = "atomic-forge/tools/serply_search" } +dependencies = [ + { name = "aiohttp" }, + { name = "atomic-agents" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "python-dotenv" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0,<4.0.0" }, + { name = "atomic-agents", editable = "." }, + { name = "pydantic", specifier = ">=2.10.3,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.6.1,<8.0.0" }, + { name = "pytest", specifier = ">=8.3.3,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.5,<1.0.0" }, + { name = "pytest-cov", specifier = ">=5.0.0,<6.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, + { name = "rich", specifier = ">=13.7.0,<14.0.0" }, +] + [[package]] name = "shellingham" version = "1.5.4"