diff --git a/src/fabledassistant/services/tools/web.py b/src/fabledassistant/services/tools/web.py index 72853ec..f95c268 100644 --- a/src/fabledassistant/services/tools/web.py +++ b/src/fabledassistant/services/tools/web.py @@ -1,4 +1,4 @@ -"""Web search, research, and image tools (require SearXNG).""" +"""Web search, research, and image tools.""" from __future__ import annotations @@ -10,29 +10,70 @@ logger = logging.getLogger(__name__) @tool( - name="search_web", + name="lookup", description=( - "Quick web lookup — returns search result snippets for factual questions, current events, " - "definitions, or version numbers. No note is saved. Use research_topic when the user wants " - "a comprehensive written report saved as a note." + "Look up a topic, concept, or factual question. Returns a concise answer from " + "Wikipedia or web sources. Use for definitions, explanations, 'what is X', " + "'how does Y work', current events, or version numbers. No note is saved. " + "For comprehensive written reports saved as notes, use research_topic instead." ), parameters={ - "query": {"type": "string", "description": "The search query"}, + "query": {"type": "string", "description": "The topic or question to look up"}, }, required=["query"], - requires="searxng", ) -async def search_web_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.research import _search_searxng +async def lookup_tool(*, user_id, arguments, **_ctx): + from fabledassistant.config import Config + from fabledassistant.services.wikipedia import wiki_summary query = arguments.get("query", "") - results = await _search_searxng(query) - if not results: - return {"success": False, "error": f"No results found for '{query}'"} + + # 1. Try Wikipedia first + wiki = await wiki_summary(query) + if wiki: + return { + "success": True, + "type": "lookup", + "source": "wikipedia", + "data": wiki, + } + + # 2. Fall back to SearXNG + article fetch + if Config.searxng_enabled(): + from fabledassistant.services.research import _search_searxng + from fabledassistant.services.rss import _fetch_full_article + + results = await _search_searxng(query) + if results: + articles: list[dict] = [] + for r in results[:2]: + url = r.get("url", "") + if not url: + continue + content = await _fetch_full_article(url) + articles.append({ + "url": url, + "title": r.get("title", url), + "snippet": r.get("snippet", ""), + "content": (content or "")[:4000], + }) + if articles: + return { + "success": True, + "type": "lookup", + "source": "web", + "data": {"query": query, "results": articles}, + } + + # 3. No sources available return { "success": True, - "type": "web_search", - "data": {"query": query, "results": results, "count": len(results)}, + "type": "lookup", + "source": "none", + "data": { + "query": query, + "message": "No results found. You can answer from your own knowledge.", + }, } @@ -42,7 +83,7 @@ async def search_web_tool(*, user_id, arguments, **_ctx): "Deep web research — searches multiple sources, synthesizes findings, and saves the result " "as a structured note with sections and citations. Use when the user says 'research', " "'look into', or wants a comprehensive write-up. Takes 30–120 seconds. " - "For a quick factual answer without saving a note, use search_web." + "For a quick factual answer without saving a note, use lookup." ), parameters={ "topic": {"type": "string", "description": "The topic or question to research"}, @@ -64,7 +105,7 @@ async def research_topic_tool(*, user_id, arguments, **_ctx): @tool( name="search_images", - description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.", + description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.", parameters={ "query": {"type": "string", "description": "The image search query"}, }, diff --git a/tests/test_lookup_tool.py b/tests/test_lookup_tool.py new file mode 100644 index 0000000..34ad27e --- /dev/null +++ b/tests/test_lookup_tool.py @@ -0,0 +1,61 @@ +import pytest +from unittest.mock import AsyncMock, patch + + +@pytest.mark.asyncio +async def test_lookup_wikipedia_hit(): + wiki_data = { + "title": "QUIC", + "extract": "QUIC is a transport layer protocol.", + "url": "https://en.wikipedia.org/wiki/QUIC", + } + + with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data): + from fabledassistant.services.tools.web import lookup_tool + result = await lookup_tool(user_id=1, arguments={"query": "QUIC"}) + + assert result["success"] is True + assert result["type"] == "lookup" + assert result["source"] == "wikipedia" + assert result["data"]["title"] == "QUIC" + + +@pytest.mark.asyncio +async def test_lookup_wikipedia_miss_searxng_fallback(): + searxng_results = [ + {"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."}, + ] + + with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \ + patch("fabledassistant.config.Config") as mock_config, \ + patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \ + patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."): + mock_config.searxng_enabled.return_value = True + from fabledassistant.services.tools.web import lookup_tool + result = await lookup_tool(user_id=1, arguments={"query": "QUIC"}) + + assert result["success"] is True + assert result["source"] == "web" + assert result["data"]["results"] + + +@pytest.mark.asyncio +async def test_lookup_wikipedia_miss_no_searxng(): + with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \ + patch("fabledassistant.config.Config") as mock_config: + mock_config.searxng_enabled.return_value = False + from fabledassistant.services.tools.web import lookup_tool + result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"}) + + assert result["success"] is True + assert result["source"] == "none" + + +@pytest.mark.asyncio +async def test_lookup_always_available(): + with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \ + patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"): + from fabledassistant.services.tools import get_tools_for_user + tools = await get_tools_for_user(user_id=1) + tool_names = {t["function"]["name"] for t in tools} + assert "lookup" in tool_names