Build and Push / build (release) Successful in 54s
- Add Paperless document search to HybridRAG pipeline - Add volatile cache (weather, forecast, news, stocks) to HybridRAG - Add include_documents and include_volatile params to hybrid_search - Add 📑 and ⚡ icons for document/volatile sources - Update Librarian prompt with new data source awareness - Fix Biographer routing: personal memory queries now route correctly - Add location keywords to Steward pre-fetch logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
951 lines
31 KiB
Python
951 lines
31 KiB
Python
"""
|
|
Librarian tools for PydanticAI agent.
|
|
|
|
These tools wrap the library-desk API and are registered with
|
|
The Librarian agent for research and knowledge management tasks.
|
|
"""
|
|
from src.agents.librarian.client import LibraryDeskClient
|
|
from src.core.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# HybridRAG Search
|
|
# ============================================================================
|
|
|
|
async def hybrid_search(
|
|
query: str,
|
|
include_web: bool = True,
|
|
include_documents: bool = True,
|
|
include_volatile: bool = True,
|
|
) -> str:
|
|
"""
|
|
Search across all knowledge sources using HybridRAG.
|
|
|
|
This is the primary research tool, combining:
|
|
- Vector search (semantic similarity over wiki pages)
|
|
- Knowledge graph (entities and relationships)
|
|
- Paperless documents (📑 indexed PDFs, scans, invoices)
|
|
- Volatile cache (⚡ weather, news, stocks - for user's configured items)
|
|
- Web search (current information from SearXNG)
|
|
|
|
Results are fused and re-ranked by relevance. Volatile data gets priority when fresh.
|
|
|
|
Args:
|
|
query: Natural language research query
|
|
include_web: Whether to include web results (default: True)
|
|
include_documents: Whether to include Paperless documents (default: True)
|
|
include_volatile: Whether to include volatile cache data (default: True)
|
|
|
|
Returns:
|
|
Formatted search results with sources and context
|
|
|
|
Examples:
|
|
hybrid_search("How does Docker orchestration work with Kubernetes?")
|
|
hybrid_search("What projects use Neo4j?", include_web=False)
|
|
hybrid_search("Find my electricity invoices", include_web=False, include_volatile=False)
|
|
hybrid_search("What's the weather in Rotterdam?") # May hit volatile cache
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
response = await client.hybrid_search(
|
|
query=query,
|
|
web_limit=5 if include_web else 0,
|
|
document_limit=5 if include_documents else 0,
|
|
volatile_limit=3 if include_volatile else 0,
|
|
)
|
|
|
|
if not response.results:
|
|
return f"No results found for '{query}'"
|
|
|
|
# Format results
|
|
output_parts = [f"## Search Results for: {query}\n"]
|
|
|
|
# Add keywords if extracted
|
|
if response.keywords:
|
|
output_parts.append(f"**Keywords:** {', '.join(response.keywords)}")
|
|
|
|
# Add related dossiers
|
|
if response.related_dossiers:
|
|
output_parts.append(
|
|
f"**Related Dossiers:** {', '.join(response.related_dossiers)}"
|
|
)
|
|
|
|
output_parts.append("")
|
|
|
|
# Add results
|
|
for i, result in enumerate(response.results, 1):
|
|
source_icon = {
|
|
"vector": "📄",
|
|
"graph": "🔗",
|
|
"web": "🌐",
|
|
"document": "📑",
|
|
"volatile": "⚡",
|
|
}.get(result.source, "•")
|
|
|
|
output_parts.append(
|
|
f"{i}. {source_icon} **{result.title}** (score: {result.score:.2f})"
|
|
)
|
|
if result.url:
|
|
output_parts.append(f" URL: {result.url}")
|
|
output_parts.append(f" {result.content[:300]}...")
|
|
output_parts.append("")
|
|
|
|
logger.info(
|
|
"librarian_hybrid_search",
|
|
query=query,
|
|
result_count=len(response.results),
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_hybrid_search_error", error=str(e), query=query)
|
|
return f"Error searching: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Wiki Operations
|
|
# ============================================================================
|
|
|
|
async def search_wiki(
|
|
query: str,
|
|
limit: int = 10,
|
|
) -> str:
|
|
"""
|
|
Search the personal wiki for relevant pages.
|
|
|
|
Performs full-text search over wiki page titles, descriptions,
|
|
and content. Use this for finding specific documents.
|
|
|
|
Args:
|
|
query: Search query
|
|
limit: Maximum results (default: 10)
|
|
|
|
Returns:
|
|
List of matching wiki pages with paths and descriptions
|
|
|
|
Examples:
|
|
search_wiki("docker setup guide")
|
|
search_wiki("architecture", limit=5)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
results = await client.search_wiki(query=query, limit=limit)
|
|
|
|
if not results:
|
|
return f"No wiki pages found for '{query}'"
|
|
|
|
output_parts = [f"## Wiki Search: {query}\n"]
|
|
|
|
for i, page in enumerate(results, 1):
|
|
output_parts.append(f"{i}. **{page.title}**")
|
|
output_parts.append(f" Path: {page.path}")
|
|
if page.description:
|
|
output_parts.append(f" {page.description}")
|
|
output_parts.append("")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_wiki_search_error", error=str(e))
|
|
return f"Error searching wiki: {str(e)}"
|
|
|
|
|
|
async def get_wiki_page(
|
|
page_id: int,
|
|
) -> str:
|
|
"""
|
|
Get the full content of a wiki page.
|
|
|
|
Use this after searching to read the complete content
|
|
of a specific page.
|
|
|
|
Args:
|
|
page_id: The page ID from search results
|
|
|
|
Returns:
|
|
Full page content including title, path, and markdown content
|
|
|
|
Examples:
|
|
get_wiki_page(42)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
page = await client.get_wiki_page(page_id=page_id)
|
|
|
|
output_parts = [
|
|
f"# {page.title}",
|
|
f"**Path:** {page.path}",
|
|
]
|
|
|
|
if page.description:
|
|
output_parts.append(f"**Description:** {page.description}")
|
|
|
|
if page.tags:
|
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
|
|
|
output_parts.append("")
|
|
output_parts.append(page.content or "(No content)")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_get_page_error", error=str(e), page_id=page_id)
|
|
return f"Error getting page {page_id}: {str(e)}"
|
|
|
|
|
|
async def list_dossiers() -> str:
|
|
"""
|
|
List all research dossiers (tag collections).
|
|
|
|
Dossiers are collections of wiki pages grouped by tag.
|
|
Use this to discover what knowledge collections exist.
|
|
|
|
Returns:
|
|
List of dossiers with page counts
|
|
|
|
Examples:
|
|
list_dossiers()
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
dossiers = await client.list_dossiers()
|
|
|
|
if not dossiers:
|
|
return "No dossiers found"
|
|
|
|
output_parts = ["## Research Dossiers\n"]
|
|
|
|
for dossier in dossiers:
|
|
output_parts.append(
|
|
f"- **{dossier.name}** ({dossier.page_count} pages)"
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_list_dossiers_error", error=str(e))
|
|
return f"Error listing dossiers: {str(e)}"
|
|
|
|
|
|
async def get_dossier_pages(
|
|
dossier_name: str,
|
|
limit: int = 20,
|
|
) -> str:
|
|
"""
|
|
Get all pages in a dossier.
|
|
|
|
Retrieves pages tagged with the specified dossier name.
|
|
|
|
Args:
|
|
dossier_name: Name of the dossier/tag
|
|
limit: Maximum pages to return
|
|
|
|
Returns:
|
|
List of pages in the dossier
|
|
|
|
Examples:
|
|
get_dossier_pages("projects")
|
|
get_dossier_pages("architecture", limit=10)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
pages = await client.list_wiki_pages(tag=dossier_name, limit=limit)
|
|
|
|
if not pages:
|
|
return f"No pages found in dossier '{dossier_name}'"
|
|
|
|
output_parts = [f"## Dossier: {dossier_name}\n"]
|
|
|
|
for page in pages:
|
|
output_parts.append(f"- **{page.title}** ({page.path})")
|
|
if page.description:
|
|
output_parts.append(f" {page.description}")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_get_dossier_error", error=str(e))
|
|
return f"Error getting dossier: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Semantic Search
|
|
# ============================================================================
|
|
|
|
async def semantic_search(
|
|
query: str,
|
|
limit: int = 10,
|
|
) -> str:
|
|
"""
|
|
Perform semantic (vector) search over documents.
|
|
|
|
Finds documents similar in meaning to the query,
|
|
even if they don't contain the exact words.
|
|
|
|
Args:
|
|
query: Natural language query
|
|
limit: Maximum results
|
|
|
|
Returns:
|
|
Matching document chunks with similarity scores
|
|
|
|
Examples:
|
|
semantic_search("containerization best practices")
|
|
semantic_search("how to handle authentication")
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
results = await client.semantic_search(query=query, limit=limit)
|
|
|
|
if not results:
|
|
return f"No semantically similar content found for '{query}'"
|
|
|
|
output_parts = [f"## Semantic Search: {query}\n"]
|
|
|
|
for i, result in enumerate(results, 1):
|
|
output_parts.append(
|
|
f"{i}. **{result.page_title}** (score: {result.score:.2f})"
|
|
)
|
|
output_parts.append(f" Path: {result.page_path}")
|
|
output_parts.append(f" {result.chunk_text[:200]}...")
|
|
output_parts.append("")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_semantic_search_error", error=str(e))
|
|
return f"Error in semantic search: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Knowledge Graph
|
|
# ============================================================================
|
|
|
|
async def explore_knowledge_graph(
|
|
entity_type: str = "Document",
|
|
limit: int = 20,
|
|
) -> str:
|
|
"""
|
|
Explore entities in the knowledge graph.
|
|
|
|
Lists nodes of a specific type to understand what's
|
|
in the knowledge base.
|
|
|
|
Args:
|
|
entity_type: Type of entity (Document, Person, Project, Concept, Technology)
|
|
limit: Maximum nodes to return
|
|
|
|
Returns:
|
|
List of entities with their properties
|
|
|
|
Examples:
|
|
explore_knowledge_graph("Person")
|
|
explore_knowledge_graph("Technology", limit=50)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
nodes = await client.list_graph_nodes(
|
|
node_type=entity_type,
|
|
limit=limit,
|
|
)
|
|
|
|
if not nodes:
|
|
return f"No {entity_type} nodes found in knowledge graph"
|
|
|
|
output_parts = [f"## Knowledge Graph: {entity_type} Entities\n"]
|
|
|
|
for node in nodes:
|
|
name = node.properties.get("name", node.properties.get("title", node.id))
|
|
output_parts.append(f"- **{name}**")
|
|
|
|
# Show a few key properties
|
|
for key in ["description", "url", "path"]:
|
|
if key in node.properties:
|
|
output_parts.append(f" {key}: {node.properties[key]}")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_explore_graph_error", error=str(e))
|
|
return f"Error exploring knowledge graph: {str(e)}"
|
|
|
|
|
|
async def find_related_entities(
|
|
entity_name: str,
|
|
) -> str:
|
|
"""
|
|
Find entities related to a given concept or entity.
|
|
|
|
Queries the knowledge graph to find documents, people,
|
|
and concepts connected to the specified entity.
|
|
|
|
Args:
|
|
entity_name: Name of the entity to find relationships for
|
|
|
|
Returns:
|
|
Related entities and their relationships
|
|
|
|
Examples:
|
|
find_related_entities("Docker")
|
|
find_related_entities("Kubernetes")
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
# Find entities mentioning or related to the search term
|
|
cypher = """
|
|
MATCH (n)
|
|
WHERE toLower(n.name) CONTAINS toLower($name)
|
|
OR toLower(n.title) CONTAINS toLower($name)
|
|
OPTIONAL MATCH (n)-[r]-(related)
|
|
RETURN n, collect(DISTINCT {type: type(r), node: related})[0..10] as relationships
|
|
LIMIT 10
|
|
"""
|
|
|
|
results = await client.query_graph(
|
|
cypher,
|
|
parameters={"name": entity_name},
|
|
)
|
|
|
|
if not results:
|
|
return f"No entities found related to '{entity_name}'"
|
|
|
|
output_parts = [f"## Entities Related to: {entity_name}\n"]
|
|
|
|
for record in results:
|
|
node = record.get("n", {})
|
|
relationships = record.get("relationships", [])
|
|
|
|
name = node.get("name", node.get("title", "Unknown"))
|
|
labels = node.get("labels", [])
|
|
|
|
output_parts.append(f"### {name}")
|
|
if labels:
|
|
output_parts.append(f"Type: {', '.join(labels)}")
|
|
|
|
if relationships:
|
|
output_parts.append("**Connections:**")
|
|
for rel in relationships[:5]: # Limit to 5 relationships
|
|
rel_type = rel.get("type", "RELATED_TO")
|
|
related_node = rel.get("node", {})
|
|
related_name = related_node.get(
|
|
"name", related_node.get("title", "Unknown")
|
|
)
|
|
output_parts.append(f" - {rel_type} → {related_name}")
|
|
|
|
output_parts.append("")
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_find_related_error", error=str(e))
|
|
return f"Error finding related entities: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Web Search & Content Extraction
|
|
# ============================================================================
|
|
|
|
async def search_web(
|
|
query: str,
|
|
limit: int = 10,
|
|
search_type: str = "web",
|
|
) -> str:
|
|
"""
|
|
Search the web and extract content from results.
|
|
|
|
This is the primary tool for finding current information online.
|
|
Results include both snippets and full extracted text from pages.
|
|
|
|
Search types:
|
|
- "web": General web search (default)
|
|
- "news": News articles
|
|
- "images": Image search
|
|
|
|
Args:
|
|
query: Search query (1-500 chars)
|
|
limit: Number of results (1-20, default: 10)
|
|
search_type: Type of search ("web", "news", or "images")
|
|
|
|
Returns:
|
|
Formatted search results with sources and extracted content
|
|
|
|
Examples:
|
|
search_web("Python 3.12 new features")
|
|
search_web("latest tech news", search_type="news", limit=5)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
response = await client.search_web(
|
|
query=query,
|
|
limit=limit,
|
|
search_type=search_type,
|
|
)
|
|
|
|
if not response.results:
|
|
return f"No results found for '{query}'"
|
|
|
|
output_parts = [f"## Web Search: {query}\n"]
|
|
output_parts.append(f"*Found {response.total_results} results in {response.search_time_ms}ms*\n")
|
|
|
|
for i, result in enumerate(response.results, 1):
|
|
output_parts.append(f"### {i}. {result.title}")
|
|
output_parts.append(f"**Source:** {result.source}")
|
|
output_parts.append(f"**URL:** {result.url}")
|
|
|
|
if result.published_date:
|
|
output_parts.append(f"**Date:** {result.published_date}")
|
|
|
|
# Use full content if available, otherwise snippet
|
|
content = result.content or result.snippet
|
|
if content:
|
|
# Truncate for readability
|
|
if len(content) > 500:
|
|
content = content[:500] + "..."
|
|
output_parts.append(f"\n{content}")
|
|
|
|
output_parts.append("")
|
|
|
|
# Add pre-formatted sources for citations
|
|
if response.sources_summary:
|
|
output_parts.append("---")
|
|
output_parts.append(response.sources_summary)
|
|
|
|
logger.info(
|
|
"librarian_web_search",
|
|
query=query,
|
|
result_count=response.total_results,
|
|
search_type=search_type,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_web_search_error", error=str(e), query=query)
|
|
return f"Error searching web: {str(e)}"
|
|
|
|
|
|
async def read_url(
|
|
url: str,
|
|
max_length: int = 5000,
|
|
) -> str:
|
|
"""
|
|
Read and extract the main content from a URL.
|
|
|
|
Use this when you have a specific URL to read, such as:
|
|
- A link the user provided
|
|
- A URL from search results you want to read in full
|
|
- Documentation or article pages
|
|
|
|
Extracts the main content, removing ads, navigation, and boilerplate.
|
|
|
|
Args:
|
|
url: The URL to read
|
|
max_length: Maximum content length (default: 5000)
|
|
|
|
Returns:
|
|
Extracted page content with metadata
|
|
|
|
Examples:
|
|
read_url("https://docs.python.org/3/library/asyncio.html")
|
|
read_url("https://example.com/article", max_length=10000)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
result = await client.extract_content(
|
|
url=url,
|
|
include_metadata=True,
|
|
max_length=max_length,
|
|
)
|
|
|
|
if not result.success:
|
|
return f"Could not read page: {result.error or 'Unknown error'}"
|
|
|
|
output_parts = []
|
|
|
|
# Header with metadata
|
|
if result.title:
|
|
output_parts.append(f"# {result.title}")
|
|
else:
|
|
output_parts.append(f"# Content from {url}")
|
|
|
|
output_parts.append(f"**URL:** {url}")
|
|
|
|
if result.author:
|
|
output_parts.append(f"**Author:** {result.author}")
|
|
|
|
if result.date:
|
|
output_parts.append(f"**Date:** {result.date}")
|
|
|
|
if result.language and result.language != "en":
|
|
output_parts.append(f"**Language:** {result.language}")
|
|
|
|
output_parts.append("")
|
|
|
|
# Main content
|
|
if result.content:
|
|
output_parts.append(result.content)
|
|
else:
|
|
output_parts.append("(No content could be extracted)")
|
|
|
|
logger.info(
|
|
"librarian_read_url",
|
|
url=url,
|
|
content_length=len(result.content) if result.content else 0,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_read_url_error", error=str(e), url=url)
|
|
return f"Error reading URL: {str(e)}"
|
|
|
|
|
|
async def read_urls_batch(
|
|
urls: list[str],
|
|
max_length: int = 2000,
|
|
) -> str:
|
|
"""
|
|
Read and extract content from multiple URLs in parallel.
|
|
|
|
More efficient than calling read_url multiple times.
|
|
Max 20 URLs per batch.
|
|
|
|
Note: Individual failures don't fail the entire batch -
|
|
failed URLs are reported but other content is still returned.
|
|
|
|
Args:
|
|
urls: List of URLs to read (max 20)
|
|
max_length: Maximum content length per URL (default: 2000)
|
|
|
|
Returns:
|
|
Extracted content from all successful URLs with failure report
|
|
|
|
Examples:
|
|
read_urls_batch(["https://example.com/1", "https://example.com/2"])
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
response = await client.extract_content_batch(
|
|
urls=urls,
|
|
include_metadata=True,
|
|
max_length=max_length,
|
|
)
|
|
|
|
output_parts = [
|
|
f"## Batch Content Extraction",
|
|
f"*Extracted {response.successful}/{response.total_urls} URLs in {response.extraction_time_ms}ms*\n",
|
|
]
|
|
|
|
# Show successful extractions
|
|
for result in response.results:
|
|
if result.success:
|
|
title = result.title or result.url
|
|
output_parts.append(f"### {title}")
|
|
output_parts.append(f"**URL:** {result.url}")
|
|
|
|
if result.content:
|
|
# Truncate for readability in batch mode
|
|
content = result.content
|
|
if len(content) > max_length:
|
|
content = content[:max_length] + "..."
|
|
output_parts.append(f"\n{content}")
|
|
|
|
output_parts.append("")
|
|
|
|
# Report failures
|
|
failed = [r for r in response.results if not r.success]
|
|
if failed:
|
|
output_parts.append("---")
|
|
output_parts.append("### Failed Extractions")
|
|
for result in failed:
|
|
output_parts.append(f"- {result.url}: {result.error}")
|
|
|
|
logger.info(
|
|
"librarian_read_urls_batch",
|
|
total=response.total_urls,
|
|
successful=response.successful,
|
|
failed=response.failed,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_read_urls_batch_error", error=str(e))
|
|
return f"Error reading URLs: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Wiki Write Operations
|
|
# ============================================================================
|
|
|
|
async def update_wiki_page(
|
|
page_id: int,
|
|
content: str | None = None,
|
|
title: str | None = None,
|
|
tags: list[str] | None = None,
|
|
description: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Update an existing wiki page.
|
|
|
|
Supports partial updates - only specify the fields you want to change.
|
|
Changes trigger automatic vector re-indexing and knowledge graph updates.
|
|
|
|
Use this for:
|
|
- Correcting information in a page
|
|
- Adding content to an existing page
|
|
- Updating tags to organize pages into dossiers
|
|
- Fixing descriptions or titles
|
|
|
|
Args:
|
|
page_id: ID of the page to update (get from search_wiki results)
|
|
content: New markdown content (optional - only if changing content)
|
|
title: New title (optional - only if renaming)
|
|
tags: New tag list (optional - replaces existing tags)
|
|
description: New description (optional)
|
|
|
|
Returns:
|
|
Confirmation with updated page details
|
|
|
|
Examples:
|
|
update_wiki_page(42, content="# Updated Content\\n\\nNew information here")
|
|
update_wiki_page(42, tags=["projects", "devops"]) # Add to dossiers
|
|
update_wiki_page(42, description="Updated description")
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
page = await client.update_wiki_page(
|
|
page_id=page_id,
|
|
content=content,
|
|
title=title,
|
|
tags=tags,
|
|
description=description,
|
|
)
|
|
|
|
# Build update summary
|
|
updated_fields = []
|
|
if content is not None:
|
|
updated_fields.append("content")
|
|
if title is not None:
|
|
updated_fields.append("title")
|
|
if tags is not None:
|
|
updated_fields.append("tags")
|
|
if description is not None:
|
|
updated_fields.append("description")
|
|
|
|
output_parts = [
|
|
f"## Page Updated: {page.title}",
|
|
f"**Path:** {page.path}",
|
|
f"**Updated fields:** {', '.join(updated_fields)}",
|
|
]
|
|
|
|
if page.tags:
|
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
|
|
|
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
|
|
|
logger.info(
|
|
"librarian_update_page",
|
|
page_id=page_id,
|
|
updated_fields=updated_fields,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_update_page_error", error=str(e), page_id=page_id)
|
|
return f"Error updating page {page_id}: {str(e)}"
|
|
|
|
|
|
async def create_wiki_page(
|
|
title: str,
|
|
path: str,
|
|
content: str,
|
|
tags: list[str],
|
|
description: str = "",
|
|
) -> str:
|
|
"""
|
|
Create a new wiki page with user-provided content.
|
|
|
|
Use this when:
|
|
- User provides specific content to add
|
|
- Creating simple notes or reminders
|
|
- The content is already known/composed
|
|
|
|
For research-backed pages where you need to gather information first,
|
|
use smart_create_wiki_page instead.
|
|
|
|
Args:
|
|
title: Page title
|
|
path: Page path (e.g., "/projects/my-project" or "/notes/meeting-2024")
|
|
content: Markdown content for the page
|
|
tags: List of tags/dossiers (e.g., ["projects", "devops"])
|
|
description: Short description of the page
|
|
|
|
Returns:
|
|
Confirmation with created page details
|
|
|
|
Examples:
|
|
create_wiki_page(
|
|
title="SSL Renewal Reminder",
|
|
path="/reminders/ssl-renewal",
|
|
content="# SSL Renewal\\n\\nRemember to renew SSL cert on Jan 15",
|
|
tags=["reminders", "infrastructure"],
|
|
description="Certificate renewal reminder"
|
|
)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
page = await client.create_wiki_page(
|
|
title=title,
|
|
path=path,
|
|
content=content,
|
|
tags=tags,
|
|
description=description,
|
|
)
|
|
|
|
output_parts = [
|
|
f"## Page Created: {page.title}",
|
|
f"**ID:** {page.id}",
|
|
f"**Path:** {page.path}",
|
|
]
|
|
|
|
if page.tags:
|
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
|
|
|
if page.description:
|
|
output_parts.append(f"**Description:** {page.description}")
|
|
|
|
output_parts.append("\n*Vector embeddings and knowledge graph will be updated automatically.*")
|
|
|
|
logger.info(
|
|
"librarian_create_page",
|
|
page_id=page.id,
|
|
title=title,
|
|
path=path,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_create_page_error", error=str(e), title=title)
|
|
return f"Error creating page: {str(e)}"
|
|
|
|
|
|
async def smart_create_wiki_page(
|
|
topic: str,
|
|
tags: list[str],
|
|
path: str | None = None,
|
|
include_web_research: bool = True,
|
|
include_wiki_search: bool = True,
|
|
) -> str:
|
|
"""
|
|
Create a wiki page with automatic research and content synthesis.
|
|
|
|
This is the RECOMMENDED way to create pages about topics. It will:
|
|
1. Search existing wiki, knowledge graph, and web for relevant information
|
|
2. Use an LLM to synthesize findings into well-structured content
|
|
3. Create the page with proper source attribution
|
|
4. Automatically link entities bidirectionally in the knowledge graph
|
|
|
|
Use this when:
|
|
- User says "Create a page about X"
|
|
- User says "Add information about X to the wiki"
|
|
- You need to research a topic before writing
|
|
- The topic would benefit from existing knowledge context
|
|
|
|
Args:
|
|
topic: The topic to research and create a page about
|
|
tags: List of tags/dossiers for categorization
|
|
path: Optional custom path (auto-generated from topic if not provided)
|
|
include_web_research: Whether to search the web (default: True)
|
|
include_wiki_search: Whether to search existing wiki (default: True)
|
|
|
|
Returns:
|
|
Summary of created page with research statistics
|
|
|
|
Examples:
|
|
smart_create_wiki_page("Docker Compose", tags=["technology", "devops"])
|
|
smart_create_wiki_page("Home network architecture", tags=["infrastructure"], include_web_research=False)
|
|
"""
|
|
try:
|
|
async with LibraryDeskClient() as client:
|
|
response = await client.smart_create_wiki_page(
|
|
topic=topic,
|
|
tags=tags,
|
|
path=path,
|
|
include_web_research=include_web_research,
|
|
include_wiki_search=include_wiki_search,
|
|
)
|
|
|
|
page = response.page
|
|
research = response.research_summary
|
|
linking = response.entity_linking
|
|
|
|
output_parts = [
|
|
f"## Page Created: {page.title}",
|
|
f"**ID:** {page.id}",
|
|
f"**Path:** {page.path}",
|
|
]
|
|
|
|
if page.tags:
|
|
output_parts.append(f"**Tags:** {', '.join(page.tags)}")
|
|
|
|
# Research summary
|
|
output_parts.append("\n### Research Summary")
|
|
output_parts.append(f"- **Wiki results used:** {research.wiki_results}")
|
|
output_parts.append(f"- **Web results used:** {research.web_results}")
|
|
output_parts.append(f"- **Graph entities found:** {research.graph_entities}")
|
|
output_parts.append(f"- **Keywords extracted:** {research.keywords_extracted}")
|
|
output_parts.append(f"- **Total sources:** {response.sources_used}")
|
|
output_parts.append(f"- **Research time:** {research.timing_ms}ms")
|
|
|
|
# Entity linking
|
|
if linking.forward_links > 0 or linking.backward_links > 0:
|
|
output_parts.append("\n### Knowledge Graph Updates")
|
|
output_parts.append(f"- **Forward links created:** {linking.forward_links}")
|
|
output_parts.append(f"- **Backward links created:** {linking.backward_links}")
|
|
output_parts.append(f"- **Related pages updated:** {linking.pages_updated}")
|
|
|
|
logger.info(
|
|
"librarian_smart_create",
|
|
topic=topic,
|
|
page_id=page.id,
|
|
sources_used=response.sources_used,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("librarian_smart_create_error", error=str(e), topic=topic)
|
|
return f"Error creating page about '{topic}': {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Tool Collection for Registration
|
|
# ============================================================================
|
|
|
|
# All tools available to The Librarian
|
|
LIBRARIAN_TOOLS = [
|
|
# Research tools (internal knowledge)
|
|
hybrid_search,
|
|
search_wiki,
|
|
get_wiki_page,
|
|
list_dossiers,
|
|
get_dossier_pages,
|
|
semantic_search,
|
|
explore_knowledge_graph,
|
|
find_related_entities,
|
|
# Web search & content extraction
|
|
search_web,
|
|
read_url,
|
|
read_urls_batch,
|
|
# Write tools
|
|
create_wiki_page,
|
|
update_wiki_page,
|
|
smart_create_wiki_page,
|
|
]
|