Files
library-desk/src/services/rag_search_service.py
T
jpmschweitzerandClaude a687b770ef fix: clear ruff so the pre-push gate passes
105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.

The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.

The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.

Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.

The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.

426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.

The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 17:04:58 +02:00

265 lines
8.1 KiB
Python

"""
RAG Search service for Library Desk.
Provides web, news, and image search with content extraction:
- Uses SearXNG for search queries
- Uses Trafilatura for content extraction
- Caches results in Redis
"""
import hashlib
import json
import logging
import time
from typing import List, Optional
from urllib.parse import urlparse
import redis.asyncio as aioredis
from src.clients.searxng_client import SearXNGClient
from src.clients.content_extractor import ContentExtractor
from src.config import Settings
from src.models.rag_search import (
SearchType,
RAGSearchResult,
RAGSearchResponse,
)
logger = logging.getLogger(__name__)
def extract_domain(url: str) -> str:
"""Extract domain name from URL, removing 'www.' prefix."""
try:
parsed = urlparse(url)
domain = parsed.netloc
return domain.removeprefix("www.")
except Exception:
return url
class RAGSearchService:
"""
Service for RAG-optimized web search with content extraction.
Combines SearXNG search with Trafilatura content extraction
and Redis caching for efficient RAG pipeline integration.
"""
def __init__(
self,
searxng_client: SearXNGClient,
content_extractor: ContentExtractor,
redis_client: aioredis.Redis,
settings: Settings
):
"""
Initialize RAG search service.
Args:
searxng_client: SearXNG search client
content_extractor: Trafilatura content extractor
redis_client: Async Redis client for caching
settings: Application settings
"""
self.searxng = searxng_client
self.extractor = content_extractor
self.redis = redis_client
self.settings = settings
self.cache_ttl = settings.search_cache_ttl
self.default_limit = settings.search_default_limit
logger.info(
f"Initialized RAGSearchService: cache_ttl={self.cache_ttl}s, "
f"default_limit={self.default_limit}"
)
def _cache_key(self, query: str, search_type: str, limit: int) -> str:
"""Generate cache key from search parameters."""
key_data = f"{query}:{search_type}:{limit}"
key_hash = hashlib.md5(key_data.encode()).hexdigest()
return f"rag_search:{key_hash}"
async def _get_cached_result(self, cache_key: str) -> Optional[RAGSearchResponse]:
"""Try to get cached search result."""
try:
cached = await self.redis.get(cache_key)
if cached:
data = json.loads(cached)
logger.debug(f"Cache hit: {cache_key}")
return RAGSearchResponse(**data)
except Exception as e:
logger.warning(f"Cache read failed: {e}")
return None
async def _set_cached_result(self, cache_key: str, result: RAGSearchResponse):
"""Cache search result."""
try:
await self.redis.setex(
cache_key,
self.cache_ttl,
result.model_dump_json()
)
logger.debug(f"Cached result: {cache_key} (TTL={self.cache_ttl}s)")
except Exception as e:
logger.warning(f"Cache write failed: {e}")
async def _search_searxng(
self,
query: str,
search_type: SearchType,
limit: int
) -> List[dict]:
"""Execute search via SearXNG based on search type."""
try:
if search_type == SearchType.WEB:
results = await self.searxng.search_general(
query=query,
limit=limit
)
elif search_type == SearchType.NEWS:
results = await self.searxng.search_news(
query=query,
limit=limit
)
elif search_type == SearchType.IMAGES:
results = await self.searxng.search_images(
query=query,
limit=limit
)
else:
results = await self.searxng.search_general(
query=query,
limit=limit
)
return results
except Exception as e:
logger.error(f"SearXNG search failed: {e}")
raise
async def _extract_content_for_results(
self,
results: List[dict]
) -> List[RAGSearchResult]:
"""Extract full content from search result URLs."""
# Get URLs for extraction
urls = [r.get("url", "") for r in results if r.get("url")]
# Extract content in parallel
extraction_results = await self.extractor.extract_batch(urls)
# Build result objects
search_results = []
for i, raw_result in enumerate(results):
url = raw_result.get("url", "")
# Find matching extraction result
extracted_content = ""
for ext_result in extraction_results:
if ext_result.url == url and ext_result.success:
extracted_content = ext_result.content
break
# Get original snippet
snippet = raw_result.get("content", "")
if len(snippet) > 300:
snippet = snippet[:300] + "..."
# Build result
search_results.append(RAGSearchResult(
title=raw_result.get("title", ""),
url=url,
content=extracted_content,
snippet=snippet,
source=extract_domain(url),
published_date=raw_result.get("publishedDate")
))
return search_results
def _generate_sources_summary(self, results: List[RAGSearchResult]) -> str:
"""Generate markdown list of source URLs."""
if not results:
return ""
lines = ["## Sources"]
for i, r in enumerate(results, 1):
lines.append(f"{i}. [{r.title}]({r.url})")
return "\n".join(lines)
async def search(
self,
query: str,
search_type: SearchType = SearchType.WEB,
limit: Optional[int] = None,
user: str = "default"
) -> RAGSearchResponse:
"""
Execute RAG-optimized search.
Args:
query: Search query string
search_type: Type of search (web, news, images)
limit: Maximum results to return (default from settings)
user: User identifier for logging/rate limiting
Returns:
RAGSearchResponse with extracted content and sources
Raises:
ValueError: If query is empty
Exception: If search fails
"""
start_time = time.time()
if not query or not query.strip():
raise ValueError("Query cannot be empty")
effective_limit = limit or self.default_limit
# Check cache
cache_key = self._cache_key(query, search_type.value, effective_limit)
cached = await self._get_cached_result(cache_key)
if cached:
return cached
logger.info(
f"RAG search: '{query}' type={search_type.value} "
f"limit={effective_limit} user={user}"
)
# Execute search
raw_results = await self._search_searxng(query, search_type, effective_limit)
# Extract content from results
search_results = await self._extract_content_for_results(raw_results)
# Generate sources summary
sources_summary = self._generate_sources_summary(search_results)
# Calculate timing
search_time_ms = int((time.time() - start_time) * 1000)
# Build response
response = RAGSearchResponse(
query=query,
search_type=search_type,
results=search_results,
total_results=len(search_results),
search_time_ms=search_time_ms,
sources_summary=sources_summary
)
# Cache result
await self._set_cached_result(cache_key, response)
logger.info(
f"RAG search completed: {len(search_results)} results "
f"in {search_time_ms}ms"
)
return response