mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17aa27739e | ||
|
|
fb6fb48816 | ||
|
|
0fac8a0aa5 | ||
|
|
072585870e | ||
|
|
436a66e163 | ||
|
|
e18de78873 | ||
|
|
b8ee7dc5b2 | ||
|
|
edb4a66564 | ||
|
|
d332f6e28d | ||
|
|
d3de3800a2 | ||
|
|
7dbfbdf5ae | ||
|
|
47c30e3c1d | ||
|
|
c16bd22c6c | ||
|
|
0237c6f4ce | ||
|
|
aa8e7079f3 | ||
|
|
4e6a092548 |
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "tools",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.10",
|
||||
"hubRef": "v1.1.15",
|
||||
"templateVersion": "1.1.6",
|
||||
"installedAt": "2026-09-07T12:54:25Z"
|
||||
}
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,10 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.10
|
||||
hub_ref: v1.1.20
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Keenable Search Tool for PraisonAI Agents.
|
||||
|
||||
Keyless (or keyed) web search using Keenable.
|
||||
|
||||
Keenable works with no API key and no extra install: it calls the keyless
|
||||
public endpoint with the Python standard library (``urllib``). Setting
|
||||
``KEENABLE_API_KEY`` switches to the authenticated endpoint for higher limits.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import KeenableSearchTool
|
||||
|
||||
keenable = KeenableSearchTool()
|
||||
results = keenable.search("AI news 2024")
|
||||
|
||||
Environment Variables:
|
||||
KEENABLE_API_KEY: Optional. When set, the authenticated endpoint is used.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.request import HTTPRedirectHandler
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEENABLE_SEARCH_URL = "https://api.keenable.ai/v1/search"
|
||||
KEENABLE_PUBLIC_SEARCH_URL = "https://api.keenable.ai/v1/search/public"
|
||||
KEENABLE_APP_TITLE = "PraisonAI"
|
||||
KEENABLE_SEARCH_TIMEOUT_SECONDS = 30
|
||||
KEENABLE_MAX_RESPONSE_BYTES = 1_000_000
|
||||
KEENABLE_MAX_RESULTS = 50
|
||||
KEENABLE_MAX_TITLE_CHARS = 256
|
||||
KEENABLE_MAX_URL_CHARS = 2048
|
||||
KEENABLE_MAX_SNIPPET_CHARS = 1200
|
||||
|
||||
|
||||
def _truncate_text(value: Any, limit: int) -> str:
|
||||
"""Trim whitespace and cap ``value`` to ``limit`` characters."""
|
||||
text = str(value or "").strip()
|
||||
if len(text) > limit:
|
||||
return text[:limit - 1] + "\u2026"
|
||||
return text
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
"""Redirect handler that refuses to follow any redirect.
|
||||
|
||||
Keenable never redirects. Following a 3xx would resend ``X-API-Key`` to
|
||||
whatever host it points at, so the redirect is dropped (urllib then raises
|
||||
the 3xx as an error, surfaced to the caller) and no second request is sent.
|
||||
"""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
"""Return ``None`` so urllib does not issue a follow-up request."""
|
||||
return None
|
||||
|
||||
|
||||
class KeenableSearchTool(BaseTool):
|
||||
"""Tool for keyless (or keyed) web search using Keenable."""
|
||||
|
||||
name = "keenable_search"
|
||||
description = "Search the web using Keenable (keyless; an API key raises limits)."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Create the tool, resolving the API key from the argument or env.
|
||||
|
||||
Args:
|
||||
api_key: Optional Keenable API key. Falls back to the
|
||||
``KEENABLE_API_KEY`` environment variable; keyless if unset.
|
||||
"""
|
||||
self.api_key = (api_key or os.getenv("KEENABLE_API_KEY") or "").strip()
|
||||
super().__init__()
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
query: Optional[str] = None,
|
||||
max_results: int = 5,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch a Keenable action; only ``search`` is supported."""
|
||||
action = action.lower().replace("-", "_")
|
||||
if action == "search":
|
||||
return self.search(query=query, max_results=max_results)
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
max_results: Maximum results to return (capped at 50).
|
||||
|
||||
Returns:
|
||||
List of dicts with ``title``, ``url``, ``snippet`` and ``provider``,
|
||||
or a single-item list with an ``error`` key on failure.
|
||||
"""
|
||||
if not query:
|
||||
return [{"error": "query is required"}]
|
||||
|
||||
result_limit = max(0, min(int(max_results), KEENABLE_MAX_RESULTS))
|
||||
if result_limit == 0:
|
||||
return []
|
||||
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, build_opener
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "praisonai-tools",
|
||||
# Required on keyless calls; names the app, carries no user identifier.
|
||||
"X-Keenable-Title": KEENABLE_APP_TITLE,
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
body = json.dumps({
|
||||
"query": query,
|
||||
"max_results": result_limit,
|
||||
"snippet_max_length": KEENABLE_MAX_SNIPPET_CHARS,
|
||||
}).encode("utf-8")
|
||||
request = Request(
|
||||
KEENABLE_SEARCH_URL if self.api_key else KEENABLE_PUBLIC_SEARCH_URL,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
opener = build_opener(_NoRedirect)
|
||||
with opener.open(request, timeout=KEENABLE_SEARCH_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(KEENABLE_MAX_RESPONSE_BYTES + 1)
|
||||
except URLError as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
if len(raw) > KEENABLE_MAX_RESPONSE_BYTES:
|
||||
return [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return [{"error": f"Keenable returned invalid JSON: {exc}"}]
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
return [{"error": "Keenable returned an invalid response shape"}]
|
||||
|
||||
results = []
|
||||
for result in payload["results"]:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
url = result.get("url")
|
||||
if not isinstance(url, str) or not url or len(url) > KEENABLE_MAX_URL_CHARS:
|
||||
continue
|
||||
# The page text is in `snippet`; `description` is usually empty.
|
||||
snippet = " ".join(str(result.get("snippet") or result.get("description") or "").split())
|
||||
results.append({
|
||||
"title": _truncate_text(result.get("title", ""), KEENABLE_MAX_TITLE_CHARS),
|
||||
"url": url,
|
||||
"snippet": _truncate_text(snippet, KEENABLE_MAX_SNIPPET_CHARS),
|
||||
"provider": "keenable",
|
||||
})
|
||||
if len(results) >= result_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def keenable_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable (keyless; KEENABLE_API_KEY raises limits)."""
|
||||
return KeenableSearchTool().search(query=query, max_results=max_results)
|
||||
@@ -0,0 +1,484 @@
|
||||
"""You.com search and content tools for PraisonAI Agents.
|
||||
|
||||
Migrated from ``praisonaiagents.tools.youdotcom_tools`` (MervinPraison/PraisonAI#6168).
|
||||
|
||||
You.com provides AI-powered web search, news search, content extraction, and image search.
|
||||
This module requires the YDC_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools.tools.youdotcom_tool import ydc_search, ydc_news, ydc_contents
|
||||
|
||||
# Search the web (unified web + news)
|
||||
results = ydc_search("AI news 2024")
|
||||
|
||||
# Get live news
|
||||
news = ydc_news("technology trends")
|
||||
|
||||
# Extract content from URLs
|
||||
content = ydc_contents(["https://example.com"])
|
||||
|
||||
# Or use the class directly
|
||||
from praisonai_tools.tools.youdotcom_tool import YouTools
|
||||
you = YouTools()
|
||||
results = you.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
class YouTools:
|
||||
"""Comprehensive tools for web search and content using You.com API.
|
||||
|
||||
You.com provides AI-powered search capabilities optimized for LLM applications.
|
||||
|
||||
Features:
|
||||
- Unified web and news search with LLM-ready snippets
|
||||
- Live news search from authoritative sources
|
||||
- Content extraction from URLs (HTML/Markdown)
|
||||
- Image search
|
||||
- Advanced search operators support
|
||||
- Geographic and language targeting
|
||||
- Freshness controls
|
||||
|
||||
Requires:
|
||||
- youdotcom package: pip install youdotcom
|
||||
- YDC_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize YouTools.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._client = None
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("YDC_API_KEY")
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create You.com client."""
|
||||
if self._client is None:
|
||||
if util.find_spec("youdotcom") is None:
|
||||
return None, "youdotcom package is not installed. Please install it using: pip install youdotcom"
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
|
||||
from youdotcom import You
|
||||
self._client = You(api_key_auth=api_key)
|
||||
return self._client, None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
Returns both web and news results in a single call with LLM-ready snippets.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
|
||||
country: ISO country code (e.g., "US", "GB", "FR")
|
||||
language: BCP 47 language code (e.g., "EN", "JP", "DE")
|
||||
offset: Pagination offset (0-9), results = offset * count
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- results: {web: [...], news: [...]}
|
||||
- metadata: {query, search_uuid, latency}
|
||||
|
||||
Search Operators:
|
||||
- site:domain.com - Search within specific domain
|
||||
- filetype:pdf - Filter by file type
|
||||
- +term - Must include term
|
||||
- -term - Exclude term
|
||||
- AND, OR, NOT - Boolean operators
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"query": query, "count": count, "offset": offset}
|
||||
|
||||
if freshness:
|
||||
kwargs["freshness"] = freshness
|
||||
if country:
|
||||
kwargs["country"] = country
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
if safesearch:
|
||||
kwargs["safesearch"] = safesearch
|
||||
if livecrawl:
|
||||
kwargs["livecrawl"] = livecrawl
|
||||
if livecrawl_formats:
|
||||
kwargs["livecrawl_formats"] = livecrawl_formats
|
||||
|
||||
response = client.search.unified(**kwargs)
|
||||
|
||||
# Convert response to dict if it's a model object
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'to_dict'):
|
||||
return response.to_dict()
|
||||
elif hasattr(response, '__dict__'):
|
||||
return self._convert_response(response)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def _convert_response(self, response) -> Dict[str, Any]:
|
||||
"""Convert SDK response object to dictionary."""
|
||||
result = {}
|
||||
|
||||
if hasattr(response, 'results'):
|
||||
results = response.results
|
||||
result['results'] = {}
|
||||
if hasattr(results, 'web') and results.web:
|
||||
result['results']['web'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'snippets': getattr(r, 'snippets', []),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'favicon_url': getattr(r, 'favicon_url', None),
|
||||
'authors': getattr(r, 'authors', []),
|
||||
}
|
||||
for r in results.web
|
||||
]
|
||||
if hasattr(results, 'news') and results.news:
|
||||
result['results']['news'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
}
|
||||
for r in results.news
|
||||
]
|
||||
|
||||
if hasattr(response, 'metadata'):
|
||||
meta = response.metadata
|
||||
result['metadata'] = {
|
||||
'query': getattr(meta, 'query', ''),
|
||||
'search_uuid': getattr(meta, 'search_uuid', ''),
|
||||
'latency': getattr(meta, 'latency', 0),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_contents(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown" (default: markdown)
|
||||
|
||||
Returns:
|
||||
Dict containing array of results with:
|
||||
- url: The fetched URL
|
||||
- title: Page title
|
||||
- html: HTML content (if format="html")
|
||||
- markdown: Markdown content (if format="markdown")
|
||||
- metadata: {site_name, favicon_url}
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# Use the contents endpoint
|
||||
response = client.search.contents(urls=urls, format=format)
|
||||
|
||||
# Convert response
|
||||
if hasattr(response, 'model_dump'):
|
||||
return {"results": response.model_dump()}
|
||||
elif isinstance(response, list):
|
||||
return {"results": [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'html': getattr(r, 'html', None),
|
||||
'markdown': getattr(r, 'markdown', None),
|
||||
'metadata': {
|
||||
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
|
||||
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
for r in response
|
||||
]}
|
||||
return {"results": response}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com contents error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def live_news(
|
||||
self,
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of news results to return
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- news: {query, results: [...], metadata}
|
||||
- Each result has: title, description, url, page_age, source_name, thumbnail
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"q": query}
|
||||
if count:
|
||||
kwargs["count"] = count
|
||||
|
||||
# Try to access live news endpoint
|
||||
if hasattr(client, 'livenews'):
|
||||
response = client.livenews.get(**kwargs)
|
||||
else:
|
||||
# Fallback: use unified search with news focus
|
||||
return self.search(query=query, count=count or 10)
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'news'):
|
||||
news = response.news
|
||||
return {
|
||||
'news': {
|
||||
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'url': getattr(r, 'url', ''),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'age': getattr(r, 'age', None),
|
||||
'source_name': getattr(r, 'source_name', None),
|
||||
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
|
||||
}
|
||||
for r in getattr(news, 'results', [])
|
||||
],
|
||||
'metadata': {
|
||||
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com live news error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def images(
|
||||
self,
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- images: {results: [...]}
|
||||
- metadata: {query, search_uuid}
|
||||
- Each result has: title, page_url, image_url
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if hasattr(client, 'images'):
|
||||
response = client.images.get(q=query)
|
||||
else:
|
||||
return {"error": "Image search not available in this SDK version"}
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'images'):
|
||||
images = response.images
|
||||
return {
|
||||
'images': {
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'page_url': getattr(r, 'page_url', ''),
|
||||
'image_url': getattr(r, 'image_url', ''),
|
||||
}
|
||||
for r in getattr(images, 'results', [])
|
||||
]
|
||||
},
|
||||
'metadata': {
|
||||
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
|
||||
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com images error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
if self._client is not None:
|
||||
# Close client if it has a close method
|
||||
if hasattr(self._client, 'close'):
|
||||
self._client.close()
|
||||
elif hasattr(self._client, '__exit__'):
|
||||
self._client.__exit__(exc_type, exc_val, exc_tb)
|
||||
return False
|
||||
|
||||
|
||||
# Standalone functions for direct import
|
||||
def ydc_search(
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
This is a convenience function that creates a YouTools instance.
|
||||
For repeated calls, consider using YouTools class directly.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year"
|
||||
country: ISO country code (e.g., "US", "GB")
|
||||
language: BCP 47 language code (e.g., "EN", "JP")
|
||||
offset: Pagination offset (0-9)
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing search results with web and news sections
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
count=count,
|
||||
freshness=freshness,
|
||||
country=country,
|
||||
language=language,
|
||||
offset=offset,
|
||||
safesearch=safesearch,
|
||||
livecrawl=livecrawl,
|
||||
livecrawl_formats=livecrawl_formats,
|
||||
)
|
||||
|
||||
|
||||
def ydc_contents(
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing extracted content for each URL
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.get_contents(urls=urls, format=format)
|
||||
|
||||
|
||||
def ydc_news(
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Dict containing news results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.live_news(query=query, count=count)
|
||||
|
||||
|
||||
def ydc_images(
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing image results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.images(query=query)
|
||||
|
||||
|
||||
# Alias for simple usage
|
||||
ydc = ydc_search
|
||||
|
||||
__all__ = ["YouTools", "ydc_search", "ydc_contents", "ydc_news", "ydc_images", "ydc"]
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.44"
|
||||
version = "0.2.46"
|
||||
description = "Extended tools for PraisonAI Agents"
|
||||
authors = [
|
||||
{name = "Mervin Praison"}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for KeenableSearchTool."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.keenable_search_tool import (
|
||||
KEENABLE_MAX_RESULTS,
|
||||
KEENABLE_MAX_SNIPPET_CHARS,
|
||||
KEENABLE_MAX_TITLE_CHARS,
|
||||
KEENABLE_MAX_URL_CHARS,
|
||||
KEENABLE_PUBLIC_SEARCH_URL,
|
||||
KEENABLE_SEARCH_URL,
|
||||
KeenableSearchTool,
|
||||
_NoRedirect,
|
||||
keenable_search,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for an ``http.client.HTTPResponse`` context manager."""
|
||||
|
||||
def __init__(self, raw: bytes):
|
||||
self._raw = raw
|
||||
|
||||
def read(self, amt=None):
|
||||
"""Return at most ``amt`` bytes, honouring the caller's read cap."""
|
||||
if amt is None:
|
||||
return self._raw
|
||||
return self._raw[:amt]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
def _payload(results):
|
||||
return json.dumps({"results": results}).encode("utf-8")
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import KeenableSearchTool as PkgTool
|
||||
|
||||
assert PkgTool is KeenableSearchTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = KeenableSearchTool()
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "keenable_search"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── Endpoint selection ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndpointSelection:
|
||||
def test_public_endpoint_without_key(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = KeenableSearchTool()
|
||||
assert tool.api_key == ""
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_PUBLIC_SEARCH_URL
|
||||
header_names = {k.lower() for k in captured["headers"]}
|
||||
assert "x-api-key" not in header_names
|
||||
assert "X-keenable-title" in captured["headers"] or "X-Keenable-Title" in captured["headers"]
|
||||
|
||||
def test_authenticated_endpoint_with_key(self):
|
||||
tool = KeenableSearchTool(api_key="secret-key")
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_SEARCH_URL
|
||||
# urllib title-cases header keys.
|
||||
assert captured["headers"].get("X-api-key") == "secret-key"
|
||||
|
||||
|
||||
# ── Result mapping + bounds ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResultMapping:
|
||||
def _run(self, tool, results):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(results))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
return tool.search("q", max_results=10)
|
||||
|
||||
def test_maps_fields(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "T", "url": "https://example.com", "snippet": "hello world"}],
|
||||
)
|
||||
assert out == [{
|
||||
"title": "T",
|
||||
"url": "https://example.com",
|
||||
"snippet": "hello world",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_skips_entries_without_url(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "no url"}, {"title": "ok", "url": "https://a.com"}],
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["url"] == "https://a.com"
|
||||
|
||||
def test_respects_max_results(self):
|
||||
tool = KeenableSearchTool()
|
||||
many = [{"url": f"https://a.com/{i}"} for i in range(10)]
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(many))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q", max_results=3)
|
||||
assert len(out) == 3
|
||||
|
||||
def test_zero_max_results_short_circuits(self):
|
||||
out = KeenableSearchTool().search("q", max_results=0)
|
||||
assert out == []
|
||||
|
||||
def test_empty_query_errors(self):
|
||||
out = KeenableSearchTool().search("", max_results=5)
|
||||
assert out == [{"error": "query is required"}]
|
||||
|
||||
|
||||
# ── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_invalid_shape_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b'{"not_results": 1}')
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out and "error" in out[0]
|
||||
|
||||
def test_oversized_response_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
oversized = b"x" * (1_000_001 + 1)
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(oversized)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_http_error_is_captured(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = RuntimeError("boom")
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "boom"}]
|
||||
|
||||
|
||||
# ── Protective bounds + redirect refusal ────────────────────────────
|
||||
|
||||
|
||||
class TestProtections:
|
||||
def _capture_body(self, tool, requested):
|
||||
"""Run a search and return the decoded JSON request body sent."""
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("q", max_results=requested)
|
||||
return captured["body"]
|
||||
|
||||
def test_caps_requested_results_at_limit(self):
|
||||
"""max_results above the hard cap is clamped before the request."""
|
||||
body = self._capture_body(KeenableSearchTool(), requested=1000)
|
||||
assert body["max_results"] == KEENABLE_MAX_RESULTS
|
||||
|
||||
def test_truncates_oversized_title(self):
|
||||
"""Titles longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"title": "T" * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["title"]) == KEENABLE_MAX_TITLE_CHARS
|
||||
assert out[0]["title"].endswith("…")
|
||||
|
||||
def test_truncates_oversized_snippet(self):
|
||||
"""Snippets longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"snippet": "s " * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["snippet"]) == KEENABLE_MAX_SNIPPET_CHARS
|
||||
assert out[0]["snippet"].endswith("…")
|
||||
|
||||
def test_skips_oversized_url(self):
|
||||
"""Results whose URL exceeds the length cap are dropped."""
|
||||
long_url = "https://a.com/" + "x" * KEENABLE_MAX_URL_CHARS
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"url": long_url}, {"url": "https://ok.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert out == [{
|
||||
"title": "",
|
||||
"url": "https://ok.com",
|
||||
"snippet": "",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_read_is_capped_at_limit(self):
|
||||
"""The body read is bounded, so an oversized stream still errors out."""
|
||||
tool = KeenableSearchTool()
|
||||
# Far larger than the cap; _FakeResponse.read honours the byte count,
|
||||
# so this verifies the read limit rather than a pre-sized buffer.
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b"x" * 5_000_000)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_refuses_redirects(self):
|
||||
"""The redirect handler drops 3xx responses so no follow-up is sent."""
|
||||
handler = _NoRedirect()
|
||||
# Returning None is urllib's signal to NOT build/send a second request.
|
||||
assert handler.redirect_request(
|
||||
MagicMock(), MagicMock(), 302, "Found", {}, "https://evil.example"
|
||||
) is None
|
||||
|
||||
|
||||
# ── Module-level convenience function ───────────────────────────────
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
def test_keenable_search_function(self):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload([{"url": "https://a.com"}]))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = keenable_search("q", max_results=2)
|
||||
assert out[0]["provider"] == "keenable"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for You.com tools (no network, no API key required)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.youdotcom_tool import (
|
||||
YouTools,
|
||||
ydc,
|
||||
ydc_search,
|
||||
ydc_contents,
|
||||
ydc_news,
|
||||
ydc_images,
|
||||
)
|
||||
|
||||
|
||||
_FIND_SPEC = "praisonai_tools.tools.youdotcom_tool.util.find_spec"
|
||||
|
||||
|
||||
def test_missing_api_key_returns_error(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
with patch(_FIND_SPEC, return_value=object()):
|
||||
for fn, arg in (
|
||||
(ydc_search, "q"),
|
||||
(ydc_contents, "https://example.com"),
|
||||
(ydc_news, "q"),
|
||||
(ydc_images, "q"),
|
||||
):
|
||||
result = fn(arg)
|
||||
assert result == {
|
||||
"error": "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_api_key_used_without_env(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
fake_module = MagicMock()
|
||||
with patch(_FIND_SPEC, return_value=object()), patch.dict(
|
||||
"sys.modules", {"youdotcom": fake_module}
|
||||
):
|
||||
client, error = YouTools(api_key="k")._get_client()
|
||||
|
||||
assert error is None
|
||||
assert client is fake_module.You.return_value
|
||||
fake_module.You.assert_called_once_with(api_key_auth="k")
|
||||
|
||||
|
||||
def test_ydc_alias():
|
||||
assert ydc is ydc_search
|
||||
|
||||
|
||||
def test_search_uses_client_and_returns_dict():
|
||||
response = MagicMock()
|
||||
response.model_dump.return_value = {"results": {"web": [], "news": []}}
|
||||
client = MagicMock()
|
||||
client.search.unified.return_value = response
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai", count=3, freshness="week")
|
||||
|
||||
assert result == {"results": {"web": [], "news": []}}
|
||||
kwargs = client.search.unified.call_args.kwargs
|
||||
assert kwargs["query"] == "ai"
|
||||
assert kwargs["count"] == 3
|
||||
assert kwargs["freshness"] == "week"
|
||||
|
||||
|
||||
def test_search_exception_is_reported_as_error():
|
||||
client = MagicMock()
|
||||
client.search.unified.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai")
|
||||
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
def test_discovered_via_package():
|
||||
import praisonai_tools.tools as tools_pkg
|
||||
|
||||
assert tools_pkg.YouTools is YouTools
|
||||
assert tools_pkg.ydc_search is ydc_search
|
||||
assert tools_pkg.ydc is ydc_search
|
||||
Reference in new issue
Block a user