""" 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, ) -> str: """ Search across all knowledge sources using HybridRAG. This is the primary research tool, combining: - Vector search (semantic similarity over documents) - Knowledge graph (entities and relationships) - Web search (current information from SearXNG) Results are fused and re-ranked by relevance. Args: query: Natural language research query include_web: Whether to include web results (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) """ try: async with LibraryDeskClient() as client: response = await client.hybrid_search( query=query, web_limit=5 if include_web 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": "🌐", }.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)}" # ============================================================================ # 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 hybrid_search, search_wiki, get_wiki_page, list_dossiers, get_dossier_pages, semantic_search, explore_knowledge_graph, find_related_entities, # Write tools create_wiki_page, update_wiki_page, smart_create_wiki_page, ]