diff --git a/.secrets.baseline b/.secrets.baseline index 8e14911e5..9bf1ecd58 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -142,7 +142,7 @@ "filename": "deploy/.env.example", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 34 + "line_number": 35 } ], "deploy/compose/README.md": [ diff --git a/deploy/.env.example b/deploy/.env.example index 0f7b367b1..95d33dde3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -18,6 +18,7 @@ NVIDIA_API_KEY= # Web search (Required) +YDC_API_KEY= TAVILY_API_KEY= # Paper search (Optional — choose one provider) diff --git a/pyproject.toml b/pyproject.toml index 864b5efaa..1ccc39953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,7 @@ dev = [ "google-scholar-paper-search", "tavily-web-search", "exa-web-search", + "you-com", "duckduckgo-news-search", "polymarket-prediction-market", "knowledge-layer[all]", @@ -251,6 +252,7 @@ aiq-agent = { workspace = true } google-scholar-paper-search = { workspace = true } tavily-web-search = { workspace = true } exa-web-search = { workspace = true } +you-com = { workspace = true } duckduckgo-news-search = { workspace = true } polymarket-prediction-market = { workspace = true } knowledge-layer = { workspace = true } diff --git a/scripts/setup.sh b/scripts/setup.sh index 0548232e1..1f9235cd3 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -116,6 +116,7 @@ echo "" echo "Installing data sources..." "${UV_BIN}" pip install -e ./sources/tavily_web_search "${UV_BIN}" pip install -e ./sources/exa_web_search +"${UV_BIN}" pip install -e ./sources/you_com "${UV_BIN}" pip install -e ./sources/google_scholar_paper_search "${UV_BIN}" pip install -e "./sources/knowledge_layer[llamaindex,foundational_rag]" echo "Data Sources installed" diff --git a/sources/you_com/README.md b/sources/you_com/README.md new file mode 100644 index 000000000..635b9b368 --- /dev/null +++ b/sources/you_com/README.md @@ -0,0 +1,59 @@ +# You.com Tools + +NAT-based tools for the You.com API. Requires a `YDC_API_KEY` environment variable or `api_key` config. +Create an API key, claim your free credits, and learn more at https://you.com/docs/quickstart. + +## Tools + +### `you_web_search` + +Retrieves relevant search results from the web using You.com [Web Search](https://you.com/docs/api-reference/search/v1-search-post). Supports livecrawl (full page content), +freshness filtering, safesearch, and news filtering. + +The [Web Search](https://you.com/docs/api-reference/search/v1-search-post) endpoint is designed to return LLM-ready web +results based on a user’s query. Based on a classification mechanism, it can return web results and news associated with +your query. If you need to feed an LLM with the results of a query that sounds like What are the latest geopolitical +updates from India, then this endpoint is the right one for you. + +Key config: +- `max_results` — number of results (1–100, default 10) +- `safesearch` - `off`, `moderate`, `strict` +- `livecrawl_mode` — `off`, `web`, `news`, `all` (default `web`) +- `livecrawl_format` — `off`, `markdown`, `html` (default `markdown`) +- `freshness` — `off`, `day`, `week`, `month`, `year` +- `max_content_length` — truncate livecrawl content to reduce token usage (default 50000, `None` for unbounded) +- `include_news_results` — True/False whether or not you want to include results categorized as `news` + +### `you_research` + + +[Research](https://you.com/docs/api-reference/research/v1-research) goes beyond a single web search. In response to your +question, it runs multiple searches, reads through the sources, and synthesizes everything into a thorough, well-cited +answer. Use it when a question is too complex for a simple lookup, and when you need a response you can actually trust +and verify. + +Key config: +- `research_effort` — `lite`, `standard` (default), `deep`, `exhaustive` + +### `you_finance_research` + +The [Finance Research API](https://you.com/docs/api-reference/finance-research/v1-finance_research) is purpose-built +for financial questions. Like the Research API, it runs multiple searches, reads through sources, and synthesizes +everything into a thorough, well-cited answer — but its retrieval index is optimized for financial data: earnings +reports, SEC filings, analyst coverage, market data, and financial news. Use it when you need credible, sourced answers +to financial questions: company fundamentals, market trends, competitive analysis, earnings summaries, or macroeconomic research. + +Key config: +- `research_effort` — `deep` (default) or `exhaustive` only + +### `you_contents` + +Extracts clean page content from URLs using the You.com [Contents API](https://you.com/docs/api-reference/contents). +Pass up to 10 URLs and receive their full content — no HTML parsing required. + +Key config: +- `formats` — list of `markdown`, `html`, `metadata` (default `["markdown", "metadata"]`) +- `crawl_timeout` — per-URL crawl timeout in seconds (1–60); increase for JavaScript-heavy pages + +We want to hear from you. If you hit a configuration issue or have questions, reach out to us at support@you.com. +For enterprise or private inquiries, reach out to api@you.com. diff --git a/sources/you_com/pyproject.toml b/sources/you_com/pyproject.toml new file mode 100644 index 000000000..d11269754 --- /dev/null +++ b/sources/you_com/pyproject.toml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools >= 64", "setuptools-scm>=8"] + +[tool.setuptools] +packages = ["you_com"] +package-dir = {"you_com" = "src"} + +[project] +name = "you-com" +version = "1.0.0" +description = "NAT-based You.com tools: web search, contents, research, and finance research" +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = {text = "Apache-2.0"} +dependencies = [ + "httpx>=0.24.0", + "pydantic>=2.0.0", + "langchain-youdotcom>=0.3.1", +] + +[project.entry-points."nat.plugins"] +you_com = "you_com.register" diff --git a/sources/you_com/src/__init__.py b/sources/you_com/src/__init__.py new file mode 100644 index 000000000..22c6f862a --- /dev/null +++ b/sources/you_com/src/__init__.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""You.com tools (web search, contents, research, finance research) for NAT.""" + +from .register import you_contents # noqa: F401 +from .register import you_finance_research # noqa: F401 +from .register import you_research # noqa: F401 +from .register import you_web_search # noqa: F401 + +__all__ = [ + "you_finance_research", + "you_research", + "you_web_search", + "you_contents", +] diff --git a/sources/you_com/src/register.py b/sources/you_com/src/register.py new file mode 100644 index 000000000..0d356f49d --- /dev/null +++ b/sources/you_com/src/register.py @@ -0,0 +1,512 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import enum +import logging +import os +from collections.abc import Callable +from collections.abc import Coroutine +from typing import Any + +from langchain_youdotcom import YouContentsTool +from langchain_youdotcom import YouFinanceResearchTool +from langchain_youdotcom import YouResearchTool +from langchain_youdotcom import YouSearchTool +from pydantic import Field +from pydantic import SecretStr +from pydantic import field_validator + +from nat.builder.builder import Builder +from nat.builder.function_info import FunctionInfo +from nat.cli.register_workflow import register_function +from nat.data_models.function import FunctionBaseConfig + +logger = logging.getLogger(__name__) + +_missing_key_warned = False + +_CACHE_MAX_SIZE = 500 + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class SafesearchMode(enum.Enum): + off = "off" + moderate = "moderate" + strict = "strict" + + +class LivecrawlMode(enum.Enum): + off = "off" + web = "web" + news = "news" + all = "all" + + +class LivecrawlFormat(enum.Enum): + off = "off" + markdown = "markdown" + html = "html" + + +class FreshnessMode(enum.Enum): + off = "off" + day = "day" + week = "week" + month = "month" + year = "year" + + +class ContentsFormat(enum.Enum): + markdown = "markdown" + html = "html" + metadata = "metadata" + + +class ResearchEffort(enum.Enum): + lite = "lite" + standard = "standard" + deep = "deep" + exhaustive = "exhaustive" + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _resolve_api_key(tool_config: "YouToolConfig") -> str | None: + return tool_config.api_key.get_secret_value() if tool_config.api_key else os.environ.get("YDC_API_KEY") + + +def _warn_missing_key_once(tool_desc: str) -> None: + # Process-once by design: all You.com tools share one key, so one warning is enough. + global _missing_key_warned + if not _missing_key_warned: + logger.warning( + "YDC_API_KEY not found. The %s tool will be registered but will " + "return an error when called. To enable: set YDC_API_KEY in your environment, " + ".env file, or specify api_key in your workflow config.", + tool_desc, + ) + _missing_key_warned = True + + +def _stub_message(label: str) -> str: + return ( + f"Error: {label} is unavailable because YDC_API_KEY is not set.\n" + "To enable this tool:\n" + "1. Get an API key from https://you.com/docs/quickstart\n" + "2. Set the API key in your environment or in your .env file\n" + "3. Restart the application" + ) + + +def _make_stub(label: str) -> FunctionInfo: + """Return a FunctionInfo that reports the tool is unavailable due to missing key.""" + + async def _stub(question: str) -> str: + return _stub_message(label) + + _stub.__doc__ = f"{label} (unavailable - missing YDC_API_KEY)." + return FunctionInfo.from_fn(_stub, description=_stub.__doc__) + + +async def _run_with_retries( + label: str, + coro_factory: Callable[[str], Coroutine[Any, Any, str]], + question: str, + *, + max_retries: int, + timeout: float | None, + cache: dict[str, str], +) -> str: + """Execute coro_factory(question) with caching, timeout, and exponential-backoff retry. + + timeout is per-attempt, not end-to-end. Worst-case wall time: timeout * max_retries + backoff. + """ + if question in cache: + logger.debug("Cache hit for query: %s", question[:80]) + return cache[question] + + for attempt in range(max_retries): + try: + coro = coro_factory(question) + result = await (asyncio.wait_for(coro, timeout=timeout) if timeout else coro) + if not result or not str(result).strip(): + raise ValueError(f"{label} returned no results.") + + if len(cache) >= _CACHE_MAX_SIZE: + cache.pop(next(iter(cache))) + cache[question] = result + return result + + except Exception as e: + if attempt == max_retries - 1: + error_msg = str(e) + if isinstance(e, ValueError): + return f"Error: {error_msg}" + if "401" in error_msg or "Unauthorized" in error_msg: + return ( + f"Error: {label} failed due to invalid API key (401 Unauthorized).\n" + "Please check your YDC_API_KEY and ensure it is valid.\n" + ) + return f"Error: {label} failed after {max_retries} attempts: {error_msg}" + await asyncio.sleep(2**attempt) + + +# --------------------------------------------------------------------------- +# Shared base config +# --------------------------------------------------------------------------- + + +class YouToolConfig(FunctionBaseConfig): + """Base config shared by all You.com tools. Not registered directly.""" + + api_key: SecretStr | None = Field(default=None, description="The API key for the You.com service") + max_retries: int = Field(default=3, ge=1, description="Maximum number of retries for the request") + timeout: float | None = Field( + default=None, + description="Timeout in seconds per attempt. None means no timeout.", + ) + + +# --------------------------------------------------------------------------- +# Tool configs +# --------------------------------------------------------------------------- + + +class YouWebSearchToolConfig(YouToolConfig, name="you_web_search"): + """ + Tool that retrieves relevant search results from web search (using You.com) for the given question. + Uses LangChain's YouSearchAPIWrapper. Requires a YDC_API_KEY environment variable or api_key config. + """ + + max_results: int = Field(default=10, ge=1, le=100, description="Maximum number of search results to return") + safesearch: SafesearchMode = Field( + default=SafesearchMode.moderate, description="Safesearch filter: 'off', 'moderate', or 'strict'" + ) + livecrawl_mode: LivecrawlMode = Field( + default=LivecrawlMode.web, + description="If you want to retrieve page contents and the format to retrieve in: " + "'off', 'web', 'news', or 'all'", + ) + livecrawl_format: LivecrawlFormat = Field( + default=LivecrawlFormat.markdown, + description="What format you want to retrieve content in: 'off', 'markdown', 'html'", + ) + freshness: FreshnessMode = Field( + default=FreshnessMode.off, + description="Restrict your search to a certain freshness: 'off', 'day', 'week', 'month', 'year'", + ) + max_content_length: int | None = Field( + default=50000, + ge=0, + description="If set, truncates each livecrawl result to specified amount of characters. " + "Can be used to reduce token usage. Defaults to 50000 to bound context size; " + "set to None for unbounded livecrawl content. " + "Titles and descriptions always remain fully in tact, only livecrawl content is truncated.", + ) + include_news_results: bool = Field( + default=False, + description="Whether or not you want to include news results. If False, filter out documents whose " + "metadata 'source' is 'news'.", + ) + + +_FINANCE_RESEARCH_EFFORTS = {ResearchEffort.deep, ResearchEffort.exhaustive} + + +class YouFinanceResearchToolConfig(YouToolConfig, name="you_finance_research"): + """ + Tool that answers financial questions using the You.com Finance Research API. + Searches a finance-optimized index (SEC filings, earnings, equity prices, macro + indicators) and returns a cited markdown response. Requires YDC_API_KEY. + """ + + research_effort: ResearchEffort = Field( + default=ResearchEffort.deep, + description="Research depth: 'deep' (faster) or 'exhaustive' (up to 300s)", + ) + + @field_validator("research_effort") + @classmethod + def _finance_effort_must_be_deep_or_exhaustive(cls, v: ResearchEffort) -> ResearchEffort: + if v not in _FINANCE_RESEARCH_EFFORTS: + raise ValueError(f"Finance research only supports 'deep' or 'exhaustive', got '{v.value}'") + return v + + +class YouContentsToolConfig(YouToolConfig, name="you_contents"): + """ + Tool that extracts clean content from URLs using the You.com Contents API. + Pass up to 10 URLs and receive their full page content as Markdown, HTML, or metadata. + Requires YDC_API_KEY. + """ + + formats: list[ContentsFormat] = Field( + default=[ContentsFormat.markdown, ContentsFormat.metadata], + description="Content formats to return: 'markdown', 'html', 'metadata'", + ) + crawl_timeout: float | None = Field( + default=None, + ge=1, + le=60, + description="Per-URL crawl timeout in seconds (1-60). Increase for JavaScript-heavy pages.", + ) + + +class YouResearchToolConfig(YouToolConfig, name="you_research"): + """ + Tool that answers open-domain questions using the You.com Research API. + Synthesizes a cited markdown answer from live web sources. Requires YDC_API_KEY. + """ + + research_effort: ResearchEffort = Field( + default=ResearchEffort.standard, + description="Research depth: 'lite', 'standard', 'deep', or 'exhaustive' (slower, more thorough)", + ) + + +# --------------------------------------------------------------------------- +# Tool registrations +# --------------------------------------------------------------------------- + + +@register_function(config_type=YouWebSearchToolConfig) +async def you_web_search(tool_config: YouWebSearchToolConfig, builder: Builder): + api_key = _resolve_api_key(tool_config) + + if not api_key: + _warn_missing_key_once("web search") + yield _make_stub("Web search") + return + + livecrawl_mode = ( + None if tool_config.livecrawl_mode.value == LivecrawlMode.off.value else tool_config.livecrawl_mode.value + ) + livecrawl_format = ( + None if tool_config.livecrawl_format.value == LivecrawlFormat.off.value else tool_config.livecrawl_format.value + ) + freshness = None if tool_config.freshness == FreshnessMode.off else tool_config.freshness.value + wrapper_kwargs = { + k: v + for k, v in { + "ydc_api_key": api_key, + "count": tool_config.max_results, + "livecrawl": livecrawl_mode, + "livecrawl_formats": livecrawl_format, + "freshness": freshness, + "safesearch": tool_config.safesearch.value, + }.items() + if v is not None + } + you_search_tool = YouSearchTool(api_wrapper=wrapper_kwargs) + + _cache: dict[str, str] = {} + + def _format_documents(search_docs) -> list[str]: + formatted_results = [] + for doc in search_docs: + if not tool_config.include_news_results and doc.metadata.get("source") == "news": + continue + title = doc.metadata.get("title", "") + url = doc.metadata.get("url", "") + description = doc.metadata.get("description", "") + content = doc.page_content + if content: + if tool_config.max_content_length: + content = content[: tool_config.max_content_length] + formatted_results.append( + f'\n\n{title}\n\n{description}\n{content}\n' + ) + else: + formatted_results.append( + f'\n\n{title}\n\n{description}\n' + ) + return formatted_results + + async def _fetch(question: str) -> str: + coro = you_search_tool.api_wrapper.results_async(question) + docs = await (asyncio.wait_for(coro, timeout=tool_config.timeout) if tool_config.timeout else coro) + if not docs: + raise ValueError("Search returned no results.") + formatted = _format_documents(docs) + if not formatted: + raise ValueError("Search returned results but failed to format.") + return "\n\n---\n\n".join(formatted) + + async def _you_web_search(question: str) -> str: + """Retrieves relevant contexts from web search (using You.com) for the given question. + + Args: + question (str): The question to be answered. + + Returns: + str: The web search results containing relevant documents and their URLs. + """ + return await _run_with_retries( + "Web search", + _fetch, + question, + max_retries=tool_config.max_retries, + timeout=None, # timeout applied inside _fetch + cache=_cache, + ) + + yield FunctionInfo.from_fn(_you_web_search, description=_you_web_search.__doc__) + + +@register_function(config_type=YouFinanceResearchToolConfig) +async def you_finance_research(tool_config: YouFinanceResearchToolConfig, builder: Builder): + api_key = _resolve_api_key(tool_config) + + if not api_key: + _warn_missing_key_once("finance research") + yield _make_stub("Finance research") + return + + finance_tool = YouFinanceResearchTool( + api_wrapper={ + "ydc_api_key": api_key, + "research_effort": tool_config.research_effort.value, + } + ) + _cache: dict[str, str] = {} + + async def _you_finance_research(question: str) -> str: + """Answers financial questions using the You.com Finance Research API. + + Searches a finance-optimized index covering SEC filings, earnings, equity + prices, macro indicators, and financial news. Returns a cited markdown response. + + Args: + question (str): The financial question to research. + + Returns: + str: Markdown answer with cited sources. + """ + return await _run_with_retries( + "Finance research", + finance_tool.api_wrapper.finance_text_async, + question, + max_retries=tool_config.max_retries, + timeout=tool_config.timeout, + cache=_cache, + ) + + yield FunctionInfo.from_fn(_you_finance_research, description=_you_finance_research.__doc__) + + +@register_function(config_type=YouResearchToolConfig) +async def you_research(tool_config: YouResearchToolConfig, builder: Builder): + api_key = _resolve_api_key(tool_config) + + if not api_key: + _warn_missing_key_once("research") + yield _make_stub("Research") + return + + research_tool = YouResearchTool( + api_wrapper={ + "ydc_api_key": api_key, + "research_effort": tool_config.research_effort.value, + } + ) + _cache: dict[str, str] = {} + + async def _you_research(question: str) -> str: + """Answers open-domain questions using the You.com Research API. + + Synthesizes a cited markdown answer from live web sources. + + Args: + question (str): The question to research. + + Returns: + str: Markdown answer with cited sources. + """ + return await _run_with_retries( + "Research", + research_tool.api_wrapper.research_text_async, + question, + max_retries=tool_config.max_retries, + timeout=tool_config.timeout, + cache=_cache, + ) + + yield FunctionInfo.from_fn(_you_research, description=_you_research.__doc__) + + +@register_function(config_type=YouContentsToolConfig) +async def you_contents(tool_config: YouContentsToolConfig, builder: Builder): + api_key = _resolve_api_key(tool_config) + + if not api_key: + _warn_missing_key_once("contents") + + async def _stub(urls: list[str]) -> str: + return _stub_message("Contents API") + + _stub.__doc__ = "Contents API (unavailable - missing YDC_API_KEY)." + yield FunctionInfo.from_fn(_stub, description=_stub.__doc__) + return + + contents_tool = YouContentsTool(api_wrapper={"ydc_api_key": api_key}) + _cache: dict[str, str] = {} + + async def _you_contents(urls: list[str]) -> str: + """Extracts clean content from web pages using the You.com Contents API. + + Fetches up to 10 URLs in parallel and returns their content as Markdown, + HTML, or metadata — ready for LLM consumption, no HTML parsing required. + + Args: + urls (list[str]): List of URLs to extract content from (max 10). + + Returns: + str: Extracted page contents formatted as Documents. + """ + + async def _fetch(_cache_key: str) -> str: + formats = [f.value for f in tool_config.formats] + docs = await contents_tool.api_wrapper.contents_async( + urls, + formats=formats, + crawl_timeout=tool_config.crawl_timeout, + ) + if not docs: + raise ValueError("Contents API returned no results.") + + parts = [] + for doc in docs: + url = doc.metadata.get("url", "") + title = doc.metadata.get("title", "") + parts.append(f'\n\n{title}\n\n{doc.page_content}\n') + return "\n\n---\n\n".join(parts) + + return await _run_with_retries( + "Contents API", + _fetch, + str(sorted(urls)), + max_retries=tool_config.max_retries, + timeout=tool_config.timeout, + cache=_cache, + ) + + yield FunctionInfo.from_fn(_you_contents, description=_you_contents.__doc__) diff --git a/sources/you_com/tests/conftest.py b/sources/you_com/tests/conftest.py new file mode 100644 index 000000000..aa528efd8 --- /dev/null +++ b/sources/you_com/tests/conftest.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared fixtures for you_com tool tests.""" + +import pytest +import you_com.register as reg + + +@pytest.fixture(autouse=True) +def _reset_warn_flag(): + reg._missing_key_warned = False + yield + reg._missing_key_warned = False + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + monkeypatch.delenv("YDC_API_KEY", raising=False) diff --git a/sources/you_com/tests/test_you_contents.py b/sources/you_com/tests/test_you_contents.py new file mode 100644 index 000000000..bbff96b5e --- /dev/null +++ b/sources/you_com/tests/test_you_contents.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the you_contents NAT tool registration.""" + +import asyncio +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +from pydantic import SecretStr +from pydantic import ValidationError +from you_com.register import ContentsFormat +from you_com.register import YouContentsToolConfig +from you_com.register import you_contents + + +def _make_doc(url: str, title: str, page_content: str = "") -> MagicMock: + doc = MagicMock() + doc.metadata = {"url": url, "title": title} + doc.page_content = page_content + return doc + + +@pytest.fixture +def mock_contents(monkeypatch): + captured = {} + + def factory(api_wrapper): + wrapper = MagicMock() + wrapper.contents_async = AsyncMock(return_value=[]) + tool = MagicMock(api_wrapper=wrapper) + captured["tool"] = tool + captured["kwargs"] = api_wrapper + return tool + + monkeypatch.setattr("you_com.register.YouContentsTool", factory) + return captured + + +class TestYouContentsToolConfig: + def test_defaults(self): + config = YouContentsToolConfig() + assert config.formats == [ContentsFormat.markdown, ContentsFormat.metadata] + assert config.crawl_timeout is None + assert config.api_key is None + assert config.max_retries == 3 + assert config.timeout is None + + def test_inherits_from_function_base_config(self): + from nat.data_models.function import FunctionBaseConfig + + assert issubclass(YouContentsToolConfig, FunctionBaseConfig) + + @pytest.mark.parametrize("bad", [0, -1, 61, 100]) + def test_crawl_timeout_out_of_range(self, bad): + with pytest.raises(ValidationError): + YouContentsToolConfig(crawl_timeout=bad) + + @pytest.mark.parametrize("good", [1, 30, 60]) + def test_crawl_timeout_valid(self, good): + config = YouContentsToolConfig(crawl_timeout=good) + assert config.crawl_timeout == good + + +class TestYouContentsStub: + async def test_stub_when_no_api_key(self): + config = YouContentsToolConfig() + builder = MagicMock() + + async with you_contents(config, builder) as info: + result = await info.single_fn(["https://example.com"]) + + assert "YDC_API_KEY" in result + assert "unavailable" in result.lower() + + +class TestYouContentsLive: + async def test_config_api_key_used(self, mock_contents, monkeypatch): + config = YouContentsToolConfig(api_key=SecretStr("key-from-config")) + builder = MagicMock() + + async with you_contents(config, builder) as _: + pass + + assert mock_contents["kwargs"].get("ydc_api_key") == "key-from-config" + + async def test_successful_call_returns_documents(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouContentsToolConfig() + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.return_value = [ + _make_doc("https://example.com", "Example", "Some content here.") + ] + out = await info.single_fn(["https://example.com"]) + + assert "https://example.com" in out + assert "Some content here." in out + + async def test_empty_result_returns_error(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouContentsToolConfig(max_retries=1) + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.return_value = [] + out = await info.single_fn(["https://example.com"]) + + assert "no results" in out.lower() + + async def test_retries_then_succeeds(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouContentsToolConfig(max_retries=3) + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.side_effect = [ + RuntimeError("transient"), + [_make_doc("https://example.com", "Example", "Recovered.")], + ] + out = await info.single_fn(["https://example.com"]) + + assert "Recovered." in out + assert mock_contents["tool"].api_wrapper.contents_async.call_count == 2 + + async def test_401_returns_friendly_message(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouContentsToolConfig(max_retries=2) + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.side_effect = RuntimeError("401 Unauthorized") + out = await info.single_fn(["https://example.com"]) + + assert "401" in out + + async def test_timeout_applied(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouContentsToolConfig(max_retries=1, timeout=0.001) + builder = MagicMock() + + async def _hang(*args, **kwargs): + await asyncio.sleep(10) + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.side_effect = _hang + out = await info.single_fn(["https://example.com"]) + + assert "error" in out.lower() + + async def test_formats_passed_to_api(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouContentsToolConfig(formats=[ContentsFormat.html]) + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.return_value = [ + _make_doc("https://example.com", "Example", "

content

") + ] + await info.single_fn(["https://example.com"]) + + _, call_kwargs = mock_contents["tool"].api_wrapper.contents_async.call_args + assert call_kwargs.get("formats") == ["html"] + + async def test_cache_returns_same_result(self, mock_contents, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouContentsToolConfig() + builder = MagicMock() + + async with you_contents(config, builder) as info: + mock_contents["tool"].api_wrapper.contents_async.return_value = [ + _make_doc("https://example.com", "Example", "cached content") + ] + out1 = await info.single_fn(["https://example.com"]) + out2 = await info.single_fn(["https://example.com"]) + + assert out1 == out2 + assert mock_contents["tool"].api_wrapper.contents_async.call_count == 1 diff --git a/sources/you_com/tests/test_you_finance_research.py b/sources/you_com/tests/test_you_finance_research.py new file mode 100644 index 000000000..6679c4874 --- /dev/null +++ b/sources/you_com/tests/test_you_finance_research.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the you_finance_research NAT tool registration.""" + +import asyncio +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +from pydantic import SecretStr +from you_com.register import ResearchEffort +from you_com.register import YouFinanceResearchToolConfig +from you_com.register import you_finance_research + + +@pytest.fixture +def mock_finance(monkeypatch): + captured = {} + + def factory(**kwargs): + wrapper = MagicMock() + wrapper.finance_text_async = AsyncMock(return_value="## Answer\n\nSome financial insight.\n") + tool = MagicMock(api_wrapper=wrapper) + captured["tool"] = tool + captured["kwargs"] = kwargs + return tool + + monkeypatch.setattr("you_com.register.YouFinanceResearchTool", factory) + return captured + + +class TestYouFinanceResearchToolConfig: + def test_defaults(self): + config = YouFinanceResearchToolConfig() + assert config.research_effort == ResearchEffort.deep + assert config.api_key is None + assert config.max_retries == 3 + assert config.timeout is None + + def test_inherits_from_function_base_config(self): + from nat.data_models.function import FunctionBaseConfig + + assert issubclass(YouFinanceResearchToolConfig, FunctionBaseConfig) + + def test_rejects_lite_effort(self): + with pytest.raises(Exception, match="deep.*exhaustive|exhaustive.*deep"): + YouFinanceResearchToolConfig(research_effort=ResearchEffort.lite) + + def test_rejects_standard_effort(self): + with pytest.raises(Exception, match="deep.*exhaustive|exhaustive.*deep"): + YouFinanceResearchToolConfig(research_effort=ResearchEffort.standard) + + +class TestYouFinanceResearchStub: + async def test_stub_when_no_api_key(self): + config = YouFinanceResearchToolConfig() + builder = MagicMock() + + async with you_finance_research(config, builder) as info: + result = await info.single_fn("anything") + + assert "YDC_API_KEY" in result + assert "unavailable" in result.lower() + + +class TestYouFinanceResearchLive: + async def test_config_api_key_used(self, mock_finance, monkeypatch): + config = YouFinanceResearchToolConfig(api_key=SecretStr("key-from-config")) + builder = MagicMock() + + async with you_finance_research(config, builder) as _: + pass + + assert mock_finance["kwargs"].get("api_wrapper", {}).get("ydc_api_key") == "key-from-config" + + async def test_successful_call_returns_markdown(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouFinanceResearchToolConfig() + builder = MagicMock() + + async with you_finance_research(config, builder) as info: + out = await info.single_fn("What drove NVIDIA revenue growth?") + + assert "Answer" in out + assert "financial insight" in out + + async def test_empty_result_returns_error(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouFinanceResearchToolConfig(max_retries=1) + builder = MagicMock() + + async with you_finance_research(config, builder) as info: + mock_finance["tool"].api_wrapper.finance_text_async.return_value = "" + out = await info.single_fn("q") + + assert "no results" in out.lower() + + async def test_retries_then_succeeds(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouFinanceResearchToolConfig(max_retries=3) + builder = MagicMock() + + async with you_finance_research(config, builder) as info: + mock_finance["tool"].api_wrapper.finance_text_async.side_effect = [ + RuntimeError("transient"), + "## Answer\n\nRecovered.\n", + ] + out = await info.single_fn("q") + + assert "Recovered" in out + assert mock_finance["tool"].api_wrapper.finance_text_async.call_count == 2 + + async def test_401_returns_friendly_message(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouFinanceResearchToolConfig(max_retries=2) + builder = MagicMock() + + async with you_finance_research(config, builder) as info: + mock_finance["tool"].api_wrapper.finance_text_async.side_effect = RuntimeError("401 Unauthorized") + out = await info.single_fn("q") + + assert "401" in out + + async def test_timeout_applied(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouFinanceResearchToolConfig(max_retries=1, timeout=0.001) + builder = MagicMock() + + async def _hang(_): + await asyncio.sleep(10) + + async with you_finance_research(config, builder) as info: + mock_finance["tool"].api_wrapper.finance_text_async.side_effect = _hang + out = await info.single_fn("q") + + assert "error" in out.lower() + + async def test_research_effort_passed_to_tool(self, mock_finance, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouFinanceResearchToolConfig(research_effort=ResearchEffort.exhaustive) + builder = MagicMock() + + async with you_finance_research(config, builder) as _: + pass + + assert mock_finance["kwargs"].get("api_wrapper", {}).get("research_effort") == "exhaustive" diff --git a/sources/you_com/tests/test_you_helpers.py b/sources/you_com/tests/test_you_helpers.py new file mode 100644 index 000000000..75acb733c --- /dev/null +++ b/sources/you_com/tests/test_you_helpers.py @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for shared helpers and tool registration smoke tests.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest +from pydantic import SecretStr +from you_com.register import YouContentsToolConfig +from you_com.register import YouFinanceResearchToolConfig +from you_com.register import YouResearchToolConfig +from you_com.register import YouToolConfig +from you_com.register import YouWebSearchToolConfig +from you_com.register import _make_stub +from you_com.register import _resolve_api_key +from you_com.register import _run_with_retries +from you_com.register import _warn_missing_key_once + + +@pytest.fixture(autouse=True) +def _reset_warn_flag(): + import you_com.register as reg + + reg._missing_key_warned = False + yield + reg._missing_key_warned = False + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + monkeypatch.delenv("YDC_API_KEY", raising=False) + + +# --------------------------------------------------------------------------- +# Registration smoke tests — guards that all three tools have the right name +# --------------------------------------------------------------------------- + + +class TestRegistrationNames: + def test_web_search_name(self): + assert YouWebSearchToolConfig._typed_model_name == "you_web_search" + + def test_finance_research_name(self): + assert YouFinanceResearchToolConfig._typed_model_name == "you_finance_research" + + def test_research_name(self): + assert YouResearchToolConfig._typed_model_name == "you_research" + + def test_contents_name(self): + assert YouContentsToolConfig._typed_model_name == "you_contents" + + def test_base_config_not_registered(self): + assert YouToolConfig._typed_model_name is None + + +# --------------------------------------------------------------------------- +# _resolve_api_key +# --------------------------------------------------------------------------- + + +class TestResolveApiKey: + def test_returns_env_key_when_no_config_key(self, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "from-env") + config = YouToolConfig() + assert _resolve_api_key(config) == "from-env" + + def test_returns_config_key(self, monkeypatch): + config = YouToolConfig(api_key=SecretStr("from-config")) + assert _resolve_api_key(config) == "from-config" + + def test_config_key_takes_precedence_over_env(self, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "from-env") + config = YouToolConfig(api_key=SecretStr("from-config")) + assert _resolve_api_key(config) == "from-config" + + def test_returns_none_when_no_key(self): + config = YouToolConfig() + assert _resolve_api_key(config) is None + + +# --------------------------------------------------------------------------- +# _warn_missing_key_once +# --------------------------------------------------------------------------- + + +class TestWarnMissingKeyOnce: + def test_warns_only_once(self, caplog): + import logging + + with caplog.at_level(logging.WARNING, logger="you_com.register"): + _warn_missing_key_once("web search") + _warn_missing_key_once("web search") + + assert sum(1 for r in caplog.records if "YDC_API_KEY" in r.message) == 1 + + +# --------------------------------------------------------------------------- +# _make_stub +# --------------------------------------------------------------------------- + + +class TestMakeStub: + async def test_stub_contains_label_and_unavailable(self): + stub = _make_stub("Finance research") + result = await stub.single_fn("anything") + assert "Finance research" in result + assert "unavailable" in result.lower() + assert "YDC_API_KEY" in result + + async def test_stub_different_labels_produce_different_messages(self): + web = _make_stub("Web search") + finance = _make_stub("Finance research") + web_result = await web.single_fn("q") + finance_result = await finance.single_fn("q") + assert "Web search" in web_result + assert "Finance research" in finance_result + assert web_result != finance_result + + +# --------------------------------------------------------------------------- +# _run_with_retries +# --------------------------------------------------------------------------- + + +class TestRunWithRetries: + async def test_success_on_first_attempt(self): + factory = AsyncMock(return_value="ok result") + cache: dict = {} + out = await _run_with_retries("T", factory, "q", max_retries=3, timeout=None, cache=cache) + assert out == "ok result" + assert cache["q"] == "ok result" + + async def test_cache_hit_skips_factory(self): + factory = AsyncMock(return_value="fresh") + cache = {"q": "cached"} + out = await _run_with_retries("T", factory, "q", max_retries=3, timeout=None, cache=cache) + assert out == "cached" + factory.assert_not_called() + + async def test_retries_on_transient_error(self, monkeypatch): + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + factory = AsyncMock(side_effect=[RuntimeError("transient"), "recovered"]) + cache: dict = {} + out = await _run_with_retries("T", factory, "q", max_retries=3, timeout=None, cache=cache) + assert out == "recovered" + assert factory.call_count == 2 + + async def test_exhausted_retries_returns_error_string(self, monkeypatch): + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + factory = AsyncMock(side_effect=RuntimeError("boom")) + cache: dict = {} + out = await _run_with_retries("T", factory, "q", max_retries=2, timeout=None, cache=cache) + assert "error" in out.lower() + assert "boom" in out + + async def test_empty_result_returns_no_results_message(self, monkeypatch): + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + factory = AsyncMock(return_value="") + cache: dict = {} + out = await _run_with_retries("T", factory, "q", max_retries=1, timeout=None, cache=cache) + assert "no results" in out.lower() + assert out.startswith("Error: ") + + async def test_401_returns_friendly_message(self, monkeypatch): + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + factory = AsyncMock(side_effect=RuntimeError("401 Unauthorized")) + cache: dict = {} + out = await _run_with_retries("T", factory, "q", max_retries=1, timeout=None, cache=cache) + assert "401" in out + + async def test_timeout_applied(self): + async def _hang(_q): + await asyncio.sleep(10) + + cache: dict = {} + out = await _run_with_retries("T", _hang, "q", max_retries=1, timeout=0.001, cache=cache) + assert "error" in out.lower() + + async def test_cache_evicts_oldest_when_full(self, monkeypatch): + from you_com import register as reg + + original = reg._CACHE_MAX_SIZE + monkeypatch.setattr(reg, "_CACHE_MAX_SIZE", 2) + try: + factory = AsyncMock(side_effect=["r1", "r2", "r3"]) + cache: dict = {} + await _run_with_retries("T", factory, "q1", max_retries=1, timeout=None, cache=cache) + await _run_with_retries("T", factory, "q2", max_retries=1, timeout=None, cache=cache) + await _run_with_retries("T", factory, "q3", max_retries=1, timeout=None, cache=cache) + assert "q1" not in cache + assert "q2" in cache + assert "q3" in cache + finally: + monkeypatch.setattr(reg, "_CACHE_MAX_SIZE", original) diff --git a/sources/you_com/tests/test_you_research.py b/sources/you_com/tests/test_you_research.py new file mode 100644 index 000000000..82ac27c7b --- /dev/null +++ b/sources/you_com/tests/test_you_research.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the you_research NAT tool registration.""" + +import asyncio +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +from pydantic import SecretStr +from you_com.register import ResearchEffort +from you_com.register import YouResearchToolConfig +from you_com.register import you_research + + +@pytest.fixture +def mock_research(monkeypatch): + captured = {} + + def factory(**kwargs): + wrapper = MagicMock() + wrapper.research_text_async = AsyncMock( + return_value="## Answer\n\nSome research findings.\n\n[1] [Source Title](https://example.com)\n" + ) + tool = MagicMock(api_wrapper=wrapper) + captured["tool"] = tool + captured["kwargs"] = kwargs + return tool + + monkeypatch.setattr("you_com.register.YouResearchTool", factory) + return captured + + +class TestYouResearchToolConfig: + def test_defaults(self): + config = YouResearchToolConfig() + assert config.research_effort == ResearchEffort.standard + assert config.api_key is None + assert config.max_retries == 3 + assert config.timeout is None + + def test_inherits_from_function_base_config(self): + from nat.data_models.function import FunctionBaseConfig + + assert issubclass(YouResearchToolConfig, FunctionBaseConfig) + + +class TestYouResearchStub: + async def test_stub_when_no_api_key(self): + config = YouResearchToolConfig() + builder = MagicMock() + + async with you_research(config, builder) as info: + result = await info.single_fn("anything") + + assert "YDC_API_KEY" in result + assert "unavailable" in result.lower() + + +class TestYouResearchLive: + async def test_config_api_key_used(self, mock_research, monkeypatch): + config = YouResearchToolConfig(api_key=SecretStr("key-from-config")) + builder = MagicMock() + + async with you_research(config, builder) as _: + pass + + assert mock_research["kwargs"].get("api_wrapper", {}).get("ydc_api_key") == "key-from-config" + + async def test_successful_call_returns_markdown(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouResearchToolConfig() + builder = MagicMock() + + async with you_research(config, builder) as info: + out = await info.single_fn("What is quantum computing?") + + assert "Answer" in out + assert "research findings" in out + + async def test_empty_result_returns_error(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouResearchToolConfig(max_retries=1) + builder = MagicMock() + + async with you_research(config, builder) as info: + mock_research["tool"].api_wrapper.research_text_async.return_value = "" + out = await info.single_fn("q") + + assert "no results" in out.lower() + + async def test_retries_then_succeeds(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouResearchToolConfig(max_retries=3) + builder = MagicMock() + + async with you_research(config, builder) as info: + mock_research["tool"].api_wrapper.research_text_async.side_effect = [ + RuntimeError("transient"), + "## Answer\n\nRecovered.\n", + ] + out = await info.single_fn("q") + + assert "Recovered" in out + assert mock_research["tool"].api_wrapper.research_text_async.call_count == 2 + + async def test_401_returns_friendly_message(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouResearchToolConfig(max_retries=2) + builder = MagicMock() + + async with you_research(config, builder) as info: + mock_research["tool"].api_wrapper.research_text_async.side_effect = RuntimeError("401 Unauthorized") + out = await info.single_fn("q") + + assert "401" in out + + async def test_timeout_applied(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouResearchToolConfig(max_retries=1, timeout=0.001) + builder = MagicMock() + + async def _hang(_): + await asyncio.sleep(10) + + async with you_research(config, builder) as info: + mock_research["tool"].api_wrapper.research_text_async.side_effect = _hang + out = await info.single_fn("q") + + assert "error" in out.lower() + + async def test_research_effort_passed_to_tool(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouResearchToolConfig(research_effort=ResearchEffort.deep) + builder = MagicMock() + + async with you_research(config, builder) as _: + pass + + assert mock_research["kwargs"].get("api_wrapper", {}).get("research_effort") == "deep" + + async def test_cache_returns_same_result(self, mock_research, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouResearchToolConfig() + builder = MagicMock() + + async with you_research(config, builder) as info: + out1 = await info.single_fn("cached question") + out2 = await info.single_fn("cached question") + + assert out1 == out2 + assert mock_research["tool"].api_wrapper.research_text_async.call_count == 1 diff --git a/sources/you_com/tests/test_you_web_search.py b/sources/you_com/tests/test_you_web_search.py new file mode 100644 index 000000000..c6ba4406a --- /dev/null +++ b/sources/you_com/tests/test_you_web_search.py @@ -0,0 +1,231 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the you_web_search NAT tool registration.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +from pydantic import SecretStr +from you_com.register import FreshnessMode +from you_com.register import YouWebSearchToolConfig +from you_com.register import you_web_search + + +def _make_doc(title="Title", url="https://example.com", description="", page_content="content", source=None): + metadata = {"title": title, "url": url, "description": description} + if source: + metadata["source"] = source + return SimpleNamespace(metadata=metadata, page_content=page_content) + + +@pytest.fixture +def mock_search(monkeypatch): + captured = {} + + def factory(api_wrapper): + wrapper = MagicMock() + wrapper.results_async = AsyncMock(return_value=[]) + tool = MagicMock(api_wrapper=wrapper) + captured["tool"] = tool + captured["kwargs"] = api_wrapper + return tool + + monkeypatch.setattr("you_com.register.YouSearchTool", factory) + return captured + + +class TestYouWebSearchToolConfig: + def test_defaults(self): + config = YouWebSearchToolConfig() + assert config.max_results == 10 + assert config.api_key is None + assert config.max_retries == 3 + assert config.safesearch.value == "moderate" + assert config.livecrawl_mode.value == "web" + assert config.livecrawl_format.value == "markdown" + assert config.freshness == FreshnessMode.off + assert config.max_content_length == 50000 + assert config.include_news_results is False + assert config.timeout is None + + def test_inherits_from_function_base_config(self): + from nat.data_models.function import FunctionBaseConfig + + assert issubclass(YouWebSearchToolConfig, FunctionBaseConfig) + + +class TestYouWebSearchStub: + async def test_stub_when_no_api_key(self): + config = YouWebSearchToolConfig() + builder = MagicMock() + + async with you_web_search(config, builder) as info: + result = await info.single_fn("anything") + + assert "YDC_API_KEY" in result + assert "unavailable" in result.lower() + + +class TestYouWebSearchLive: + async def test_config_api_key_used(self, mock_search, monkeypatch): + config = YouWebSearchToolConfig(api_key=SecretStr("key-from-config")) + builder = MagicMock() + + async with you_web_search(config, builder) as _: + pass + + assert mock_search["kwargs"].get("ydc_api_key") == "key-from-config" + + async def test_successful_search_formats_documents(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(max_results=2) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [ + _make_doc("Title A", "https://a.example", page_content="Body A"), + _make_doc("Title B", "https://b.example", page_content="Body B"), + ] + out = await info.single_fn("query") + + assert "Title A" in out + assert "Title B" in out + assert "Body A" in out + assert "Body B" in out + assert "---" in out + + async def test_max_content_length_truncates(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(max_content_length=5) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [ + _make_doc(page_content="abcdefghijklmnop"), + ] + out = await info.single_fn("q") + + assert "abcde" in out + assert "abcdefgh" not in out + + async def test_default_bounds_oversized_livecrawl_content(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(max_results=10) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [ + _make_doc(page_content="x" * 200_000) for _ in range(10) + ] + out = await info.single_fn("q") + + assert len(out) <= 10 * 50000 + 10_000 + + async def test_news_source_filtered_by_default(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(include_news_results=False) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [ + _make_doc("Web", "https://web.example", page_content="web body"), + _make_doc("News", "https://news.example", page_content="news body", source="news"), + ] + out = await info.single_fn("q") + + assert "https://web.example" in out + assert "https://news.example" not in out + + async def test_include_news_results_keeps_news(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(include_news_results=True) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [ + _make_doc("Web", "https://web.example", page_content="web body"), + _make_doc("News", "https://news.example", page_content="news body", source="news"), + ] + out = await info.single_fn("q") + + assert "https://web.example" in out + assert "https://news.example" in out + + async def test_empty_results_returns_error(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(max_retries=1) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.return_value = [] + out = await info.single_fn("q") + + assert "no results" in out.lower() + + async def test_retries_then_succeeds(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouWebSearchToolConfig(max_retries=3) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.side_effect = [ + RuntimeError("transient"), + [_make_doc("T", "https://a.example", page_content="ok")], + ] + out = await info.single_fn("q") + + assert "ok" in out + assert mock_search["tool"].api_wrapper.results_async.call_count == 2 + + async def test_401_returns_friendly_message(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + monkeypatch.setattr("you_com.register.asyncio.sleep", AsyncMock()) + config = YouWebSearchToolConfig(max_retries=2) + builder = MagicMock() + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.side_effect = RuntimeError("401 Unauthorized") + out = await info.single_fn("q") + + assert "401" in out + + async def test_wrapper_kwargs_exclude_none_freshness(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig() # freshness=off → mapped to None before request + builder = MagicMock() + + async with you_web_search(config, builder) as _: + pass + + assert "freshness" not in mock_search["kwargs"] + + async def test_timeout_applied(self, mock_search, monkeypatch): + monkeypatch.setenv("YDC_API_KEY", "test-key") + config = YouWebSearchToolConfig(max_retries=1, timeout=0.001) + builder = MagicMock() + + async def _hang(_): + await asyncio.sleep(10) + + async with you_web_search(config, builder) as info: + mock_search["tool"].api_wrapper.results_async.side_effect = _hang + out = await info.single_fn("q") + + assert "error" in out.lower() diff --git a/uv.lock b/uv.lock index ee6196c52..e5e151a29 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "knowledge-layer", "polymarket-prediction-market", "tavily-web-search", + "you-com", ] overrides = [ { name = "authlib", specifier = ">=1.6.11,<2" }, @@ -266,6 +267,7 @@ dev = [ { name = "ruff" }, { name = "tavily-web-search" }, { name = "yapf" }, + { name = "you-com" }, ] [package.metadata] @@ -330,6 +332,7 @@ dev = [ { name = "ruff", specifier = "~=0.15.1" }, { name = "tavily-web-search", editable = "sources/tavily_web_search" }, { name = "yapf", specifier = ">=0.40.0" }, + { name = "you-com", editable = "sources/you_com" }, ] [[package]] @@ -2931,6 +2934,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" }, ] +[[package]] +name = "langchain-youdotcom" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "youdotcom" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/9e/4d6fe20f509f04b245502b0816b2282232b4ef4ade14d98ec2d79b1869a4/langchain_youdotcom-0.3.1.tar.gz", hash = "sha256:dd0ad3358ad3590c0fffe673d0de173ce6d0e7930792893f0c5a5019b0b042b4", size = 104026, upload-time = "2026-06-12T22:04:28.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/e7/5e772c2ca51cf4fbe9d3dc606faae8f2024b3c08243f2d664b445ae6aff6/langchain_youdotcom-0.3.1-py3-none-any.whl", hash = "sha256:b76a01bdb2e7668ef3649370a625eeedd65a34a8fd68fbb997c74fd0d232b518", size = 12515, upload-time = "2026-06-12T22:04:29.299Z" }, +] + [[package]] name = "langgraph" version = "1.2.8" @@ -7278,6 +7294,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] +[[package]] +name = "you-com" +version = "1.0.0" +source = { editable = "sources/you_com" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-youdotcom" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.24.0" }, + { name = "langchain-youdotcom", specifier = ">=0.3.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, +] + +[[package]] +name = "youdotcom" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/6b/1d3dd77f5968086d1606972697074ddd07b72abcb514c38a39feda77031f/youdotcom-2.3.0.tar.gz", hash = "sha256:f35e4ba53bb7f235100e7b7fcccfdcb84e9a08c85bb053d4e6c76bf29ecc7547", size = 71806, upload-time = "2026-03-04T03:21:11.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/0c/a57beaa961914c9014ee7ffc16697844874e4a7910848f11a55b45b58619/youdotcom-2.3.0-py3-none-any.whl", hash = "sha256:5750d0bcc88cf666b88e36e116c9775607db5d3491a02afad219eb9d4ec97eb0", size = 89388, upload-time = "2026-03-04T03:21:09.308Z" }, +] + [[package]] name = "zict" version = "3.0.0"