Add SearXNG web search for current information
- Add searxng.py service for web queries via SearXNG API - Integrate search into ai_chat.py with AI-driven search decisions - AI determines if query needs current info, then searches automatically - Add SEARXNG_URL, SEARXNG_ENABLED, SEARXNG_MAX_RESULTS config options - Update documentation in README.md, CLAUDE.md, and .env.example
This commit is contained in:
@@ -7,7 +7,7 @@ import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from daemon_boyfriend.config import settings
|
||||
from daemon_boyfriend.services import AIService, ConversationManager, Message
|
||||
from daemon_boyfriend.services import AIService, ConversationManager, Message, SearXNGService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,6 +71,9 @@ class AIChatCog(commands.Cog):
|
||||
self.bot = bot
|
||||
self.ai_service = AIService()
|
||||
self.conversations = ConversationManager()
|
||||
self.search_service: SearXNGService | None = None
|
||||
if settings.searxng_enabled and settings.searxng_url:
|
||||
self.search_service = SearXNGService(settings.searxng_url)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
@@ -138,10 +141,23 @@ class AIChatCog(commands.Cog):
|
||||
# Add current message to history for the API call
|
||||
messages = history + [Message(role="user", content=user_message)]
|
||||
|
||||
# Check if we should search the web
|
||||
search_context = await self._maybe_search(user_message)
|
||||
|
||||
# Build system prompt with search context if available
|
||||
system_prompt = self.ai_service.get_system_prompt()
|
||||
if search_context:
|
||||
system_prompt += (
|
||||
"\n\n--- Web Search Results ---\n"
|
||||
"Use the following current information from the web to help answer the user's question. "
|
||||
"Cite sources when relevant.\n\n"
|
||||
f"{search_context}"
|
||||
)
|
||||
|
||||
# Generate response
|
||||
response = await self.ai_service.chat(
|
||||
messages=messages,
|
||||
system_prompt=self.ai_service.get_system_prompt(),
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
|
||||
# Save the exchange to history
|
||||
@@ -154,6 +170,64 @@ class AIChatCog(commands.Cog):
|
||||
|
||||
return response.content
|
||||
|
||||
async def _maybe_search(self, query: str) -> str | None:
|
||||
"""Determine if a search is needed and perform it.
|
||||
|
||||
Args:
|
||||
query: The user's message
|
||||
|
||||
Returns:
|
||||
Formatted search results or None if search not needed/available
|
||||
"""
|
||||
if not self.search_service:
|
||||
return None
|
||||
|
||||
# Ask the AI if this query needs current information
|
||||
decision_prompt = (
|
||||
"You are a search decision assistant. Your ONLY job is to decide if the user's "
|
||||
"question requires current/real-time information from the internet.\n\n"
|
||||
"Respond with ONLY 'SEARCH: <query>' if a web search would help answer the question "
|
||||
"(replace <query> with optimal search terms), or 'NO_SEARCH' if the question can be "
|
||||
"answered with general knowledge.\n\n"
|
||||
"Examples that NEED search:\n"
|
||||
"- Current events, news, recent happenings\n"
|
||||
"- Current weather, stock prices, sports scores\n"
|
||||
"- Latest version of software, current documentation\n"
|
||||
"- Information about specific people, companies, or products that may have changed\n"
|
||||
"- 'What time is it in Tokyo?' or any real-time data\n\n"
|
||||
"Examples that DON'T need search:\n"
|
||||
"- General knowledge, science, math, history\n"
|
||||
"- Coding help, programming concepts\n"
|
||||
"- Personal advice, opinions, creative writing\n"
|
||||
"- Explanations of concepts or 'how does X work'"
|
||||
)
|
||||
|
||||
try:
|
||||
decision = await self.ai_service.chat(
|
||||
messages=[Message(role="user", content=query)],
|
||||
system_prompt=decision_prompt,
|
||||
)
|
||||
|
||||
response_text = decision.content.strip()
|
||||
|
||||
if response_text.startswith("SEARCH:"):
|
||||
search_query = response_text[7:].strip()
|
||||
logger.info(f"AI decided to search for: {search_query}")
|
||||
|
||||
results = await self.search_service.search(
|
||||
query=search_query,
|
||||
max_results=settings.searxng_max_results,
|
||||
)
|
||||
|
||||
if results:
|
||||
return self.search_service.format_results_for_context(results)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Search decision/execution failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot) -> None:
|
||||
"""Load the AI Chat cog."""
|
||||
|
||||
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
|
||||
20, description="Max messages to keep in conversation memory per user"
|
||||
)
|
||||
|
||||
# SearXNG Configuration
|
||||
searxng_url: str | None = Field(None, description="SearXNG instance URL for web search")
|
||||
searxng_enabled: bool = Field(True, description="Enable web search capability")
|
||||
searxng_max_results: int = Field(5, ge=1, le=20, description="Maximum search results to fetch")
|
||||
|
||||
def get_api_key(self) -> str:
|
||||
"""Get the API key for the configured provider."""
|
||||
key_map = {
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
from .ai_service import AIService
|
||||
from .conversation import ConversationManager
|
||||
from .providers import AIResponse, Message
|
||||
from .searxng import SearXNGService
|
||||
|
||||
__all__ = [
|
||||
"AIService",
|
||||
"AIResponse",
|
||||
"Message",
|
||||
"ConversationManager",
|
||||
"SearXNGService",
|
||||
]
|
||||
|
||||
107
src/daemon_boyfriend/services/searxng.py
Normal file
107
src/daemon_boyfriend/services/searxng.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""SearXNG search service for web queries."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""A single search result."""
|
||||
|
||||
title: str
|
||||
url: str
|
||||
content: str
|
||||
|
||||
|
||||
class SearXNGService:
|
||||
"""Service for searching the web via SearXNG."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = 10) -> None:
|
||||
"""Initialize the SearXNG service.
|
||||
|
||||
Args:
|
||||
base_url: The base URL of the SearXNG instance
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
categories: str = "general",
|
||||
max_results: int = 5,
|
||||
) -> list[SearchResult]:
|
||||
"""Search the web using SearXNG.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
categories: Search categories (general, images, news, etc.)
|
||||
max_results: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
url = f"{self.base_url}/search"
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
logger.debug(f"Searching SearXNG for: {query}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"SearXNG returned status {response.status}")
|
||||
return []
|
||||
|
||||
data = await response.json()
|
||||
results = []
|
||||
|
||||
for item in data.get("results", [])[:max_results]:
|
||||
results.append(
|
||||
SearchResult(
|
||||
title=item.get("title", ""),
|
||||
url=item.get("url", ""),
|
||||
content=item.get("content", ""),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(f"SearXNG returned {len(results)} results")
|
||||
return results
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"SearXNG request failed: {e}")
|
||||
return []
|
||||
except TimeoutError:
|
||||
logger.error("SearXNG request timed out")
|
||||
return []
|
||||
|
||||
def format_results_for_context(self, results: list[SearchResult]) -> str:
|
||||
"""Format search results as context for the AI.
|
||||
|
||||
Args:
|
||||
results: List of search results
|
||||
|
||||
Returns:
|
||||
Formatted string with search results
|
||||
"""
|
||||
if not results:
|
||||
return "No search results found."
|
||||
|
||||
formatted = []
|
||||
for i, result in enumerate(results, 1):
|
||||
formatted.append(f"[{i}] {result.title}\n URL: {result.url}\n {result.content}")
|
||||
|
||||
return "\n\n".join(formatted)
|
||||
Reference in New Issue
Block a user