feat(phase3): add The Librarian agent with library-desk integration

Library-Desk API Client:
- Async HTTP client with httpx for library-desk API
- HybridRAG search (vector + graph + web)
- Wiki operations (search, get, list, create, update)
- Smart page creation with HybridRAG research
- Semantic vector search and knowledge graph queries
- Dossier browsing and health checks

Librarian Tools (11 total):
- Research: hybrid_search, search_wiki, get_wiki_page, semantic_search
- Browse: list_dossiers, get_dossier_pages, explore_knowledge_graph
- Graph: find_related_entities
- Write: create_wiki_page, update_wiki_page, smart_create_wiki_page

Agent:
- PydanticAI agent with research assistant personality
- System prompt with research and writing workflows
- Streaming support via run_librarian_stream()

Capability:
- LIBRARIAN_CAPABILITY definition for Household Registry
- Automatic registration on startup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 21:27:09 +01:00
co-authored by Claude Opus 4.5
parent 92c0d5d770
commit f6f37b341b
5 changed files with 1783 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
"""
The Librarian - Expert agent for research and knowledge management.
A PydanticAI agent that provides research assistance through
the library-desk API, offering:
- HybridRAG search across all knowledge sources
- Wiki and document management
- Semantic search and knowledge graph exploration
"""
from typing import Any, Optional
from pydantic_ai import Agent
from src.agents.librarian.tools import (
create_wiki_page,
explore_knowledge_graph,
find_related_entities,
get_dossier_pages,
get_wiki_page,
hybrid_search,
list_dossiers,
search_wiki,
semantic_search,
smart_create_wiki_page,
update_wiki_page,
)
from src.core.config import config
from src.core.logging_config import get_logger
logger = get_logger(__name__)
# Librarian system prompt
LIBRARIAN_SYSTEM_PROMPT = """You are The Librarian, an expert research assistant in the Tatlock household.
Your role is to help users find, understand, synthesize, and manage information from:
- The personal wiki (Wiki.js) containing documentation and notes
- The knowledge graph (Neo4j) with entities and relationships
- Vector embeddings (Qdrant) for semantic search
- Web search (SearXNG) for current information
## Your Personality
- Scholarly and thorough in your research
- Cite your sources and provide context
- Organize information clearly
- Suggest related topics when relevant
- Acknowledge limitations when information is incomplete
## Your Tools
### Research Tools
- **hybrid_search**: Your primary research tool - searches all sources at once
- **search_wiki**: Find specific wiki pages by keyword
- **semantic_search**: Find conceptually similar content
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
- **list_dossiers** / **get_dossier_pages**: Browse knowledge collections
### Wiki Reading Tools
- **get_wiki_page**: Read full content of a wiki page by ID
- ALWAYS use this to fetch and read page content when summarizing
- Use after search_wiki to get the full text of a specific page
### Wiki Writing Tools
- **smart_create_wiki_page**: Create a page with automatic research (PREFERRED)
- **This is the DEFAULT choice when user asks to create a wiki page about a topic**
- When user says "Create a page about X" or "Add X to the wiki" without providing specific content, ALWAYS use this tool
- Automatically researches the topic from wiki, graph, and web
- Synthesizes content with proper source attribution
- Creates bidirectional links in knowledge graph
- **create_wiki_page**: Create a page with user-provided content
- ONLY use when user provides specific text/content they want added verbatim
- For simple notes, reminders, or quick additions with exact content
- **update_wiki_page**: Update an existing page (partial updates)
- Use when: "Update the page about X", "Fix this info", "Add to dossier"
- First search_wiki to find the page, then get_wiki_page to read it
- Only specify fields you want to change
## Research Approach
1. Start with hybrid_search for broad queries
2. Use search_wiki for specific document lookups
3. **ALWAYS use get_wiki_page to fetch full content** before summarizing a page
4. Use semantic_search when looking for conceptually similar content
5. Explore the knowledge graph to find connections between concepts
6. Synthesize and summarize findings clearly
## Writing Approach
When asked to create or update wiki content:
1. **"Create a page about X" (no specific content provided)**: Use smart_create_wiki_page
- This is the PREFERRED tool for topic-based page creation
- It researches first and creates comprehensive, well-sourced content
2. **User provides exact text to add**: Use create_wiki_page with their content
3. **Updating existing pages**:
- Search for the page with search_wiki
- Fetch full content with get_wiki_page
- Make edits and use update_wiki_page
4. **Organizing into dossiers**: Use update_wiki_page with just the tags field
## Response Format
Your responses are returned to Tatlock (the butler) who will synthesize them into a final answer for the user. Keep this in mind:
- Lead with the key findings or confirmation of action
- Include relevant sources and citations
- When summarizing wiki pages, fetch and read them first
- Note any gaps in available information
- Be concise but thorough - Tatlock will format the final response
- Structure your findings clearly so they can be easily integrated with other responses
"""
# Lazy initialization to avoid connection issues during imports
_librarian_agent: Optional[Agent[None, str]] = None
def _create_librarian_agent() -> Agent[None, str]:
"""Create the Librarian PydanticAI agent."""
# Import required classes for Ollama configuration
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.ollama import OllamaProvider
# PydanticAI expects Ollama base URL to end with /v1
clean_host = str(config.OLLAMA_HOST).rstrip('/')
base_url = f"{clean_host}/v1"
# Create Ollama model with provider
model = OpenAIChatModel(
model_name=config.OLLAMA_DEFAULT_MODEL,
provider=OllamaProvider(base_url=base_url)
)
agent: Agent[None, str] = Agent(
model=model,
system_prompt=LIBRARIAN_SYSTEM_PROMPT,
retries=2,
)
# Register research tools
agent.tool_plain(hybrid_search)
agent.tool_plain(search_wiki)
agent.tool_plain(semantic_search)
agent.tool_plain(list_dossiers)
agent.tool_plain(get_dossier_pages)
agent.tool_plain(explore_knowledge_graph)
agent.tool_plain(find_related_entities)
# Register wiki read tools
agent.tool_plain(get_wiki_page)
# Register wiki write tools
agent.tool_plain(create_wiki_page)
agent.tool_plain(update_wiki_page)
agent.tool_plain(smart_create_wiki_page)
logger.info(
"librarian_agent_created",
model=config.OLLAMA_DEFAULT_MODEL,
tool_count=11,
)
return agent
def get_librarian_agent() -> Agent[None, str]:
"""
Get the Librarian agent instance (lazy initialization).
Returns:
PydanticAI Agent configured for research tasks
"""
global _librarian_agent
if _librarian_agent is None:
_librarian_agent = _create_librarian_agent()
return _librarian_agent
async def run_librarian(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
) -> str:
"""
Execute a research task with The Librarian.
This is the main entry point for delegating research tasks
to The Librarian from Tatlock or other agents.
Args:
task: The research task or question
context: Additional context from conversation
message_history: Optional conversation history
Returns:
Research results and findings
Example:
result = await run_librarian(
task="Find information about Docker networking",
context="User is setting up a homelab",
)
"""
agent = get_librarian_agent()
# Build prompt with context if provided
prompt = task
if context:
prompt = f"Context: {context}\n\nTask: {task}"
logger.info(
"librarian_task_started",
task=task[:100],
has_context=bool(context),
has_history=bool(message_history),
)
try:
result = await agent.run(
prompt,
message_history=message_history,
)
logger.info(
"librarian_task_completed",
task=task[:50],
output_length=len(result.output),
)
return result.output
except Exception as e:
logger.error(
"librarian_task_error",
task=task[:50],
error=str(e),
exc_info=True,
)
return f"The Librarian encountered an error: {str(e)}"
async def run_librarian_stream(
task: str,
context: str = "",
message_history: Optional[list[Any]] = None,
):
"""
Execute a research task with streaming output.
Yields text deltas as The Librarian generates the response.
Args:
task: The research task or question
context: Additional context from conversation
message_history: Optional conversation history
Yields:
str: Text deltas from the response
Example:
async for delta in run_librarian_stream("Find Docker docs"):
print(delta, end="", flush=True)
"""
agent = get_librarian_agent()
# Build prompt with context if provided
prompt = task
if context:
prompt = f"Context: {context}\n\nTask: {task}"
logger.info(
"librarian_stream_started",
task=task[:100],
)
try:
async with agent.run_stream(
prompt,
message_history=message_history,
) as response:
async for delta in response.stream_text(delta=True):
yield delta
logger.info("librarian_stream_completed", task=task[:50])
except Exception as e:
logger.error(
"librarian_stream_error",
task=task[:50],
error=str(e),
exc_info=True,
)
yield f"\n\nThe Librarian encountered an error: {str(e)}"