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>
265 lines
8.1 KiB
Python
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
|