feat: migrate web search from tatlock_core to Librarian
Move web search functionality to The Librarian agent, integrating with the library-desk /rag/search endpoint for enhanced search capabilities. Changes: - Add search_web, read_url, read_urls_batch tools to Librarian - Add WebSearchResult, ContentExtractionResult models to client - Add search_web, extract_content, extract_content_batch client methods - Update Librarian capability with web/url/internet domains - Remove search_web from tatlock_core tools and toolset - Update Tatlock system prompt to delegate web search to Librarian - Add comprehensive unit tests for new Librarian tools - Clean up legacy src/agents/tools.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,47 @@ class ResearchSummary(BaseModel):
|
||||
timing_ms: int = 0
|
||||
|
||||
|
||||
class WebSearchResult(BaseModel):
|
||||
"""Result from web search via /rag/search."""
|
||||
title: str
|
||||
url: str
|
||||
content: str = "" # Full extracted text via Trafilatura
|
||||
snippet: str = "" # Original search engine snippet
|
||||
source: str = "" # Domain name
|
||||
published_date: Optional[str] = None
|
||||
|
||||
|
||||
class WebSearchResponse(BaseModel):
|
||||
"""Response from /rag/search endpoint."""
|
||||
query: str
|
||||
search_type: str
|
||||
results: list[WebSearchResult] = Field(default_factory=list)
|
||||
total_results: int = 0
|
||||
search_time_ms: int = 0
|
||||
sources_summary: str = "" # Pre-formatted markdown citations
|
||||
|
||||
|
||||
class ContentExtractionResult(BaseModel):
|
||||
"""Result from content extraction."""
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
content: str = ""
|
||||
author: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
success: bool = True
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BatchExtractionResponse(BaseModel):
|
||||
"""Response from batch content extraction."""
|
||||
results: list[ContentExtractionResult] = Field(default_factory=list)
|
||||
total_urls: int = 0
|
||||
successful: int = 0
|
||||
failed: int = 0
|
||||
extraction_time_ms: int = 0
|
||||
|
||||
|
||||
class EntityLinking(BaseModel):
|
||||
"""Entity linking results from smart-create."""
|
||||
forward_links: int = 0
|
||||
@@ -685,6 +726,186 @@ class LibraryDeskClient:
|
||||
logger.warning("library_desk_health_check_failed", error=str(e))
|
||||
return False
|
||||
|
||||
# ========================================================================
|
||||
# RAG Search (Web Search with Content Extraction)
|
||||
# ========================================================================
|
||||
|
||||
async def search_web(
|
||||
self,
|
||||
query: str,
|
||||
user: str | None = None,
|
||||
search_type: str = "web",
|
||||
limit: int = 10,
|
||||
) -> WebSearchResponse:
|
||||
"""
|
||||
Search the web and extract content from results.
|
||||
|
||||
Uses SearXNG for search and Trafilatura for content extraction.
|
||||
Returns both snippets and full extracted text.
|
||||
|
||||
Args:
|
||||
query: Search query (1-500 chars)
|
||||
user: User identifier for tracking
|
||||
search_type: "web", "news", or "images"
|
||||
limit: Number of results (1-20)
|
||||
|
||||
Returns:
|
||||
WebSearchResponse with results and pre-formatted sources
|
||||
"""
|
||||
user = user or get_user()
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"limit": limit,
|
||||
"user": user or "tatlock-librarian",
|
||||
}
|
||||
|
||||
logger.info("library_desk_web_search", query=query, limit=limit)
|
||||
|
||||
response = await client.post("/rag/search", json=payload, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
results = [
|
||||
WebSearchResult(
|
||||
title=r.get("title", ""),
|
||||
url=r.get("url", ""),
|
||||
content=r.get("content", ""),
|
||||
snippet=r.get("snippet", ""),
|
||||
source=r.get("source", ""),
|
||||
published_date=r.get("published_date"),
|
||||
)
|
||||
for r in data.get("results", [])
|
||||
]
|
||||
|
||||
return WebSearchResponse(
|
||||
query=data.get("query", query),
|
||||
search_type=data.get("search_type", search_type),
|
||||
results=results,
|
||||
total_results=data.get("total_results", len(results)),
|
||||
search_time_ms=data.get("search_time_ms", 0),
|
||||
sources_summary=data.get("sources_summary", ""),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Content Extraction
|
||||
# ========================================================================
|
||||
|
||||
async def extract_content(
|
||||
self,
|
||||
url: str,
|
||||
include_metadata: bool = True,
|
||||
max_length: int = 5000,
|
||||
) -> ContentExtractionResult:
|
||||
"""
|
||||
Extract main content from a URL.
|
||||
|
||||
Uses Trafilatura for intelligent content extraction,
|
||||
removing boilerplate, ads, and navigation.
|
||||
|
||||
Note: Uses soft failure pattern - check result.success field.
|
||||
|
||||
Args:
|
||||
url: URL to extract content from
|
||||
include_metadata: Whether to extract author, date, etc.
|
||||
max_length: Maximum content length
|
||||
|
||||
Returns:
|
||||
ContentExtractionResult (check .success and .error fields)
|
||||
"""
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
"url": url,
|
||||
"include_metadata": include_metadata,
|
||||
"max_length": max_length,
|
||||
}
|
||||
|
||||
logger.debug("library_desk_extract_content", url=url)
|
||||
|
||||
response = await client.post("/content/extract", json=payload, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
result = data.get("result", {})
|
||||
|
||||
return ContentExtractionResult(
|
||||
url=result.get("url", url),
|
||||
title=result.get("title"),
|
||||
content=result.get("content", ""),
|
||||
author=result.get("author"),
|
||||
date=result.get("date"),
|
||||
language=result.get("language"),
|
||||
success=result.get("success", False),
|
||||
error=result.get("error"),
|
||||
)
|
||||
|
||||
async def extract_content_batch(
|
||||
self,
|
||||
urls: list[str],
|
||||
include_metadata: bool = True,
|
||||
max_length: int = 2000,
|
||||
) -> BatchExtractionResponse:
|
||||
"""
|
||||
Extract content from multiple URLs in parallel.
|
||||
|
||||
More efficient than sequential calls. Max 20 URLs per batch.
|
||||
|
||||
Note: Uses soft failure pattern - individual failures don't
|
||||
throw errors, check each result's .success field.
|
||||
|
||||
Args:
|
||||
urls: List of URLs to extract (max 20)
|
||||
include_metadata: Whether to extract author, date, etc.
|
||||
max_length: Maximum content length per URL
|
||||
|
||||
Returns:
|
||||
BatchExtractionResponse with results and stats
|
||||
"""
|
||||
client = self._ensure_client()
|
||||
|
||||
payload = {
|
||||
"urls": urls[:20], # Server limit
|
||||
"include_metadata": include_metadata,
|
||||
"max_length": max_length,
|
||||
}
|
||||
|
||||
logger.info("library_desk_extract_batch", url_count=len(urls))
|
||||
|
||||
response = await client.post(
|
||||
"/content/extract/batch",
|
||||
json=payload,
|
||||
timeout=60.0, # Longer timeout for batch
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
results = [
|
||||
ContentExtractionResult(
|
||||
url=r.get("url", ""),
|
||||
title=r.get("title"),
|
||||
content=r.get("content", ""),
|
||||
author=r.get("author"),
|
||||
date=r.get("date"),
|
||||
language=r.get("language"),
|
||||
success=r.get("success", False),
|
||||
error=r.get("error"),
|
||||
)
|
||||
for r in data.get("results", [])
|
||||
]
|
||||
|
||||
return BatchExtractionResponse(
|
||||
results=results,
|
||||
total_urls=data.get("total_urls", len(urls)),
|
||||
successful=data.get("successful", 0),
|
||||
failed=data.get("failed", 0),
|
||||
extraction_time_ms=data.get("extraction_time_ms", 0),
|
||||
)
|
||||
|
||||
|
||||
# Global client factory
|
||||
async def get_library_client() -> LibraryDeskClient:
|
||||
|
||||
Reference in New Issue
Block a user