Compare commits
+8
-3
@@ -8,7 +8,14 @@ API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_PREFIX=/v1
|
||||
|
||||
# Ollama Configuration
|
||||
# Anthropic Configuration (Claude - preferred backend)
|
||||
# Set ANTHROPIC_API_KEY to enable Claude as the default backend
|
||||
# Without an API key, Tatlock uses Ollama exclusively
|
||||
# ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
PREFER_CLOUD_BACKEND=true
|
||||
|
||||
# Ollama Configuration (local fallback when Claude unavailable)
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
OLLAMA_DEFAULT_MODEL=mistral-nemo:latest
|
||||
OLLAMA_TIMEOUT=120
|
||||
@@ -21,7 +28,6 @@ SEARXNG_TIMEOUT=30
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_MEMORY_DB=1
|
||||
REDIS_BENCHMARK_DB=6
|
||||
REDIS_TIMEOUT=5
|
||||
|
||||
# Qdrant Configuration
|
||||
@@ -33,7 +39,6 @@ QDRANT_PORT=6333
|
||||
# - development: DEBUG (maximum verbosity)
|
||||
# - production: WARNING (minimal noise)
|
||||
# Uncomment to override: LOG_LEVEL=INFO
|
||||
ENABLE_BENCHMARKS=true
|
||||
# Note: Log format is auto-selected based on ENVIRONMENT (console for dev, json for production)
|
||||
|
||||
# User Configuration
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: make setup
|
||||
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
|
||||
release:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+11
-6
@@ -46,27 +46,32 @@ ENV/
|
||||
.ipynb_checkpoints/
|
||||
*.ipynb
|
||||
|
||||
# Testing & Coverage
|
||||
# Caches (pytest, mypy, ruff)
|
||||
.cache/
|
||||
|
||||
# Build output (coverage, logs)
|
||||
build/
|
||||
|
||||
# Legacy cache/output locations (in case tools fall back)
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# Testing
|
||||
.tox/
|
||||
.nox/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
|
||||
# Linting
|
||||
.ruff_cache/
|
||||
|
||||
# Logs
|
||||
logs/*
|
||||
!logs/traces/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document contains instructions and documentation references for AI assistants working with this codebase.
|
||||
|
||||
> **📖 Important**: Before working on this project, read [PHILOSOPHY.md](PHILOSOPHY.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||
> **📖 Important**: Before working on this project, read [docs/philosophy.md](docs/philosophy.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
|
||||
+113
-1
@@ -7,6 +7,114 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.0] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming SSE compatibility with Open WebUI** - Switch from `exclude_none=True` to `exclude_unset=True` for SSE chunk serialization; `exclude_none` was too aggressive — it stripped `finish_reason: null` from intermediate chunks (which OpenAI includes), while `exclude_unset` correctly omits only fields never passed to the constructor (like `reasoning_content` on content-only chunks) while preserving explicitly-set `finish_reason: null`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Project structure consolidation** - Moved documentation to `docs/`, consolidated all config into `pyproject.toml`, replaced `wakeup.sh`/`pytest.ini`/`requirements*.txt` with `Makefile` + `pyproject.toml`
|
||||
- **CI test gate** - Unit tests now gate release and build jobs in Gitea Actions workflow
|
||||
- **Build output organization** - Tool caches in `.cache/`, generated output (coverage, logs) in `build/`
|
||||
|
||||
## [2.0.5] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming JSON compatibility** - Exclude null fields from streaming chunks using `exclude_none=True`; OpenAI's API omits null fields entirely, and including them (e.g., `content: null`, `reasoning_content: null`) caused parsing issues in Open WebUI
|
||||
|
||||
## [2.0.4] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Open WebUI streaming compatibility** - Replaced `sse_starlette` `EventSourceResponse` with plain `StreamingResponse` for chat completions; `sse_starlette` added `\r\n` line endings and extra SSE fields that Open WebUI couldn't parse
|
||||
|
||||
## [2.0.3] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Steward analysis leaking into responses** - Removed internal routing analysis (`DELEGATE: tatlock_core...`) from user-visible reasoning in both streaming and non-streaming paths
|
||||
|
||||
## [2.0.2] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **tool_choice format incompatibility** - Removed `extra_body` tool_choice hack for Claude backend; PydanticAI handles tool_choice natively for Anthropic, preventing infinite tool call loops
|
||||
- **CI trigger** - Changed workflow trigger from `release:published` to `push:tags:v[0-9]*`
|
||||
|
||||
## [2.0.1] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Expert agent registration failure** - `AnthropicModel` does not accept `api_key` directly; now passes it via `AnthropicProvider`
|
||||
|
||||
## [2.0.0] - 2026-02-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude backend support (Claudification Phase 1)** - All agents now prefer Claude over Ollama
|
||||
- New `src/anthropic/` module with model selector and health check
|
||||
- `get_model()` factory returns Claude if available, Ollama as fallback
|
||||
- Startup health check caches Claude API availability
|
||||
- Configuration: `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `PREFER_CLOUD_BACKEND`
|
||||
- 200k token context when using Claude backend
|
||||
|
||||
- **Steward dual-backend support** - Direct API calls to Claude or Ollama
|
||||
- `_call_claude()`: Anthropic Messages API path
|
||||
- `_call_ollama()`: Existing Ollama generate API path (preserved)
|
||||
- Automatic fallback: if Claude call fails mid-request, retries with Ollama
|
||||
|
||||
- **Claudification project tracking** - `PROJECT_CLAUDIFICATION.md` with Phase 1/2 roadmap
|
||||
|
||||
### Changed
|
||||
|
||||
- **All PydanticAI agents refactored to use `get_model()`**:
|
||||
- Tatlock (6 instantiation locations)
|
||||
- Librarian
|
||||
- Biographer
|
||||
- Housekeeper
|
||||
- **`initialize_application()` is now async** - Supports async Claude health check at startup
|
||||
- **Dependencies**: `pydantic-ai-slim[openai,anthropic]` replaces `pydantic-ai-slim[openai]`
|
||||
- **Startup logging** now includes backend selection info (claude/ollama)
|
||||
- **Agent creation logging** now includes backend and model info
|
||||
|
||||
### Removed
|
||||
|
||||
- Stale `tests/core/test_benchmarks.py` (benchmark system was removed in v1.10.0)
|
||||
|
||||
## [1.11.0] - 2025-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Paperless document integration** - HybridRAG now includes indexed PDFs and scanned documents from Paperless-ngx
|
||||
- New `include_documents` parameter in `hybrid_search` tool
|
||||
- 📑 icon for document sources in search results
|
||||
- Librarian prompt updated with document awareness
|
||||
|
||||
- **Volatile cache integration** - HybridRAG now includes pre-fetched real-time data
|
||||
- New `include_volatile` parameter in `hybrid_search` tool
|
||||
- ⚡ icon for volatile sources in search results
|
||||
- Supports weather, forecast, news, stock, crypto, sun, air_quality namespaces
|
||||
- Librarian prompt updated with volatile cache awareness (user-configured items only)
|
||||
|
||||
- **Biographer routing in Steward** - Personal memory queries now correctly route to The Biographer
|
||||
- Added explicit routing rules for "where do I live", "what car do I drive", etc.
|
||||
- Added biographer delegation examples to Steward prompt
|
||||
- Location keywords ("live", "where", "home") now trigger profile pre-fetch
|
||||
|
||||
### Changed
|
||||
|
||||
- **LibraryDeskClient.hybrid_search** - Now passes full config including `document_limit`, `volatile_limit`, and enable flags
|
||||
- **Steward guidelines** - Clarified that research queries about TOPICS go to Librarian, queries about USER go to Biographer
|
||||
|
||||
## [1.10.1] - 2025-12-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tatlock's excessive apologizing** - Strengthened personality prompt to prevent unnecessary apologies after successful Librarian delegations. Added explicit "do NOT apologize" instructions to both system prompt and synthesis prompt.
|
||||
|
||||
## [1.10.0] - 2025-12-22
|
||||
|
||||
### Added
|
||||
@@ -825,7 +933,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- CORS middleware
|
||||
- Exception handlers (OpenAI-compatible error format)
|
||||
|
||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.10.0...main
|
||||
[Unreleased]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.1.0...main
|
||||
[2.1.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.0.5...v2.1.0
|
||||
[2.0.5]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v2.0.0...v2.0.5
|
||||
[2.0.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.11.0...v2.0.0
|
||||
[1.11.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.10.0...v1.11.0
|
||||
[1.10.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.9.0...v1.10.0
|
||||
[1.9.0]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.6...v1.9.0
|
||||
[1.8.6]: https://git.schweitz.net/jpmschweitzer/tatlock/compare/v1.8.5...v1.8.6
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code-specific notes for this project. For general development instructions, architecture, coding standards, and deployment — see [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Setup & Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv and install all dependencies
|
||||
make test # Unit tests (no external services)
|
||||
make test-integration # Integration tests (needs Claude/Ollama)
|
||||
make run # Start dev server on port 8777
|
||||
make lint # Ruff linter + formatter check
|
||||
make typecheck # Mypy
|
||||
make clean # Remove caches and build artifacts
|
||||
```
|
||||
|
||||
Dependencies are in `pyproject.toml` (`[project.dependencies]` and `[project.optional-dependencies.dev]`).
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
**ASGITransport does NOT trigger FastAPI lifespan events.** The session-scoped `_initialize_app` fixture in `tests/conftest.py` calls `initialize_application()` explicitly via `asyncio.run()`. Without this, `check_claude_health()` never runs and `_claude_available` stays `None`, causing all tests to silently fall back to Ollama.
|
||||
|
||||
**AsyncIO scope mismatch.** `asyncio_default_fixture_loop_scope = function` is set in `pyproject.toml`. Session-scoped async fixtures cause `ScopeMismatch` errors. The fix is to use a sync fixture with `asyncio.run()` for session-scoped initialization.
|
||||
|
||||
**Ollama is unreliable for tool calling.** `mistral-nemo` on Ollama often does mental math instead of calling calculator tools, and frequently gets wrong answers. Claude reliably calls tools. If integration tests give wrong math answers, check which backend is actually being used.
|
||||
|
||||
**Integration test timeouts.** Set to 120s to match `OLLAMA_TIMEOUT` config. Ollama on tower-of-joy can be slow, especially on first request.
|
||||
|
||||
**`get_benchmark_store` does not exist.** The benchmarking module (`src/core/benchmarks.py`) was never implemented. `scripts/benchmark_analysis.py` also references it and is broken. Do not add mocks for it in tests.
|
||||
|
||||
**Steward tests need household registry.** Use `register_household_members()` (sync) in fixtures, not `initialize_application()` (async). The steward extracts capabilities from the registry.
|
||||
+2
-2
@@ -5,8 +5,8 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
|
||||
@@ -1,920 +0,0 @@
|
||||
# Tatlock Implementation Roadmap
|
||||
|
||||
> **Reference**: See [PHILOSOPHY.md](PHILOSOPHY.md) for the target architecture and vision
|
||||
|
||||
This document outlines the phased implementation plan to transform the current OpenAI-compatible API into the full Tatlock household butler system.
|
||||
|
||||
## Current State (v1.2.0 - Phase F Complete)
|
||||
|
||||
**What we have**:
|
||||
- ✅ **The Orchestrator** - FastAPI infrastructure layer
|
||||
- OpenAI-compatible API endpoints (Responses API + Chat Completions)
|
||||
- Streaming coordination and conversation management
|
||||
- Response format with reasoning support
|
||||
- Test infrastructure (~400 tests)
|
||||
- ✅ **Two-Tier Architecture**
|
||||
- The Steward analyzes requests and recommends capabilities
|
||||
- Tatlock coordinates execution with scoped tools
|
||||
- Real-time streaming of analysis and reasoning
|
||||
- ✅ **Household Staff**
|
||||
- **Tatlock** (Butler): Primary interface with witty personality
|
||||
- **The Steward**: Request analysis and capability recommendation
|
||||
- **The Librarian**: Research via library-desk HybridRAG + wiki
|
||||
- **The Biographer**: User memory, profiles, preferences, semantic recall
|
||||
- ✅ **Core Tools**
|
||||
- Calculator, Date/Time toolkit, Web search (SearXNG)
|
||||
- ✅ **Memory System**
|
||||
- Direct access layer (memory_service) for fast lookups
|
||||
- Vector storage (Qdrant) for semantic recall
|
||||
- Session cache (Redis) with 24h TTL
|
||||
- Multi-tenancy via ContextVar
|
||||
- ✅ Mock agent (lorem-tester for testing)
|
||||
|
||||
**What we need**:
|
||||
- More household staff (Developer, Secretary, Handyman, Housekeeper)
|
||||
- MCP (Model Context Protocol) integration
|
||||
- Dynamic model switching for specialized tasks
|
||||
- Full multi-tenant database (PostgreSQL)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Real LLM Integration - PydanticAI + Tools
|
||||
|
||||
**Goal**: Connect to actual language models and establish the base plumbing
|
||||
|
||||
**Note**: Ollama is an external service dependency (already running separately)
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **PydanticAI Integration** ✅
|
||||
- PydanticAI → Ollama connection ✅
|
||||
- Agent creation patterns ✅
|
||||
- Streaming response handling ✅
|
||||
- Error handling and retries ✅
|
||||
|
||||
2. **Convert Tatlock Agent** ✅
|
||||
- Convert Tatlock agent from mock to PydanticAI ✅
|
||||
- British butler personality prompt ✅
|
||||
- Research-oriented mindset ✅
|
||||
- Streaming to reasoning output ✅
|
||||
- Tool calling framework setup ✅
|
||||
|
||||
3. **Permanent Tools** ✅
|
||||
- Calculator: Safe mathematical expression evaluation ✅
|
||||
- Date/Time toolkit: Current time, relative dates, time differences ✅
|
||||
- Web search: SearXNG integration (external service) ✅
|
||||
- Tool registration with PydanticAI ✅
|
||||
|
||||
4. **Testing Infrastructure** ✅
|
||||
- Integration tests with real LLM ✅
|
||||
- Tool functionality tests ✅
|
||||
- Response quality validation ✅
|
||||
- 131 tests, 81.78% coverage ✅
|
||||
|
||||
### Success Criteria
|
||||
- [x] **PydanticAI agents can call Ollama** (mistral-nemo:latest)
|
||||
- [x] **Streaming works end-to-end**
|
||||
- [x] **Tool calling framework functional**
|
||||
- [x] **Permanent tools working** (calculator, date/time, search)
|
||||
- [x] **Tests pass with real LLM**
|
||||
- [ ] Can switch models dynamically (e.g., Codestral for code)
|
||||
|
||||
### Status
|
||||
**✅ MOSTLY COMPLETE** - Tatlock agent functional with permanent tools
|
||||
|
||||
### Remaining Work
|
||||
- Dynamic model switching for specialized tasks (e.g., Codestral for coding)
|
||||
|
||||
### Why First?
|
||||
Without real LLM integration, we can't meaningfully implement the Steward/Butler pattern. Everything else depends on having actual AI agents working.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Orchestration Layer - The Steward
|
||||
|
||||
**Goal**: Implement the first-tier LLM call for tool/agent selection
|
||||
|
||||
**Purpose**: The Steward performs crucial preparatory work before Tatlock engages with a request. By analyzing incoming requests and determining which tools, services, and household staff members will be needed, the Steward creates a curated recommendation that streamlines Tatlock's work and prevents cognitive overload.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
The Steward operates as the first tier in the two-tier request flow:
|
||||
|
||||
```
|
||||
User Request → Orchestrator → Steward Analysis → Recommendations → Tatlock (with scoped tools/agents)
|
||||
```
|
||||
|
||||
**Key Principle**: The Steward narrows the scope to only relevant capabilities, making Tatlock's decision-making cleaner and more focused.
|
||||
|
||||
### Deliverables
|
||||
|
||||
#### 1. Tool & Agent Registry System
|
||||
|
||||
**Purpose**: Centralized catalog of all available capabilities for the Steward to recommend
|
||||
|
||||
**Implementation Details**:
|
||||
- **Registry Module** (`src/core/registry.py`)
|
||||
- Tool registration decorator pattern
|
||||
- Agent registration with capability metadata
|
||||
- Category-based organization (computation, information, automation, communication)
|
||||
- Dynamic tool/agent discovery and loading
|
||||
|
||||
- **Tool Metadata Schema**
|
||||
```python
|
||||
{
|
||||
"name": "calculator",
|
||||
"category": "computation",
|
||||
"description": "Safe mathematical expression evaluation",
|
||||
"capabilities": ["arithmetic", "algebra", "trigonometry"],
|
||||
"cost": "low", # computational cost indicator
|
||||
"requires_network": false
|
||||
}
|
||||
```
|
||||
|
||||
- **Agent Metadata Schema**
|
||||
```python
|
||||
{
|
||||
"name": "developer",
|
||||
"role": "The Developer",
|
||||
"category": "technical",
|
||||
"description": "Software development assistance",
|
||||
"domains": ["code_generation", "debugging", "architecture"],
|
||||
"specialized_model": "codestral", # optional
|
||||
"cost": "high"
|
||||
}
|
||||
```
|
||||
|
||||
- **Registry API**
|
||||
- `get_all_tools()` - List all available tools
|
||||
- `get_all_agents()` - List all expert agents
|
||||
- `get_by_category(category)` - Filter by category
|
||||
- `search_by_capability(query)` - Semantic search (future: vector search)
|
||||
|
||||
**Testing**:
|
||||
- Unit tests for registration and retrieval
|
||||
- Test dynamic loading of new tools/agents
|
||||
- Validate metadata schemas
|
||||
|
||||
#### 2. Steward PydanticAI Agent
|
||||
|
||||
**Purpose**: First-tier LLM that analyzes requests and recommends relevant tools/agents
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
- **Agent Module** (`src/agents/steward.py`)
|
||||
```python
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic import BaseModel
|
||||
|
||||
class StewardRecommendation(BaseModel):
|
||||
"""Structured output from Steward analysis"""
|
||||
recommended_tools: list[str]
|
||||
recommended_agents: list[str]
|
||||
reasoning: str
|
||||
estimated_complexity: str # "simple", "moderate", "complex"
|
||||
requires_multi_step: bool
|
||||
|
||||
steward = Agent(
|
||||
'ollama:mistral-nemo', # Same base model as Tatlock
|
||||
result_type=StewardRecommendation,
|
||||
system_prompt="""..."""
|
||||
)
|
||||
```
|
||||
|
||||
- **System Prompt Engineering**
|
||||
- Role: Estate steward responsible for efficient household coordination
|
||||
- Task: Analyze requests to determine needed resources
|
||||
- Output: Structured recommendations with reasoning
|
||||
- Constraints: Be conservative (recommend only truly relevant capabilities)
|
||||
- Context: Full registry of available tools and agents
|
||||
|
||||
- **Steward Tools**
|
||||
```python
|
||||
@steward.tool
|
||||
def get_available_capabilities(ctx: RunContext) -> dict:
|
||||
"""Get catalog of all available tools and agents."""
|
||||
return {
|
||||
"tools": registry.get_all_tools(),
|
||||
"agents": registry.get_all_agents()
|
||||
}
|
||||
```
|
||||
|
||||
- **Request Analysis Flow**
|
||||
1. Receive user request
|
||||
2. Query capability registry via tool
|
||||
3. Analyze request for required capabilities
|
||||
4. Generate structured recommendation
|
||||
5. Format as note to Tatlock
|
||||
|
||||
**Testing**:
|
||||
- Test various request types (simple, complex, multi-domain)
|
||||
- Verify recommendations are relevant and not over-inclusive
|
||||
- Test structured output parsing
|
||||
- Validate reasoning quality
|
||||
|
||||
#### 3. Request Preprocessing Pipeline
|
||||
|
||||
**Purpose**: Integration layer that routes requests through Steward before Tatlock
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
- **Preprocessing Module** (`src/core/preprocessing.py`)
|
||||
```python
|
||||
async def preprocess_request(user_request: str) -> EnrichedRequest:
|
||||
"""
|
||||
1. Call Steward for analysis
|
||||
2. Get recommendations
|
||||
3. Enrich original request
|
||||
4. Return scoped context for Tatlock
|
||||
"""
|
||||
# Get Steward analysis
|
||||
steward_result = await steward.run(user_request)
|
||||
recommendations = steward_result.data
|
||||
|
||||
# Create note to Tatlock
|
||||
steward_note = format_steward_note(recommendations)
|
||||
|
||||
# Build scoped tool/agent list
|
||||
scoped_tools = get_scoped_tools(recommendations.recommended_tools)
|
||||
scoped_agents = get_scoped_agents(recommendations.recommended_agents)
|
||||
|
||||
return EnrichedRequest(
|
||||
original_request=user_request,
|
||||
steward_note=steward_note,
|
||||
available_tools=scoped_tools,
|
||||
available_agents=scoped_agents,
|
||||
metadata=recommendations
|
||||
)
|
||||
```
|
||||
|
||||
- **Note Formatting**
|
||||
```
|
||||
=== Internal Note from the Steward ===
|
||||
|
||||
Request Analysis:
|
||||
{steward reasoning}
|
||||
|
||||
Recommended Tools:
|
||||
- calculator: For mathematical computations
|
||||
- web_search: To find current information
|
||||
|
||||
Recommended Household Staff:
|
||||
- The Developer: For code generation assistance
|
||||
|
||||
Estimated Complexity: moderate
|
||||
===================================
|
||||
|
||||
[Original User Request]
|
||||
```
|
||||
|
||||
- **Orchestrator Integration**
|
||||
- Modify `src/responses/service.py` to call preprocessing
|
||||
- Prepend Steward note to request before sending to Tatlock
|
||||
- Limit Tatlock's tool access to recommended tools only
|
||||
- Stream Steward's reasoning to output
|
||||
|
||||
**Testing**:
|
||||
- Integration tests for full preprocessing flow
|
||||
- Test request enrichment format
|
||||
- Verify tool scoping works correctly
|
||||
- Test streaming of Steward reasoning
|
||||
|
||||
#### 4. Real-Time Transparency
|
||||
|
||||
**Purpose**: Stream Steward's analysis to user's reasoning output
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
- **Streaming Integration** (`src/responses/streaming.py`)
|
||||
- Add Steward analysis phase to stream
|
||||
- Format as reasoning item
|
||||
- Include recommendation summary
|
||||
|
||||
- **Example Output to User**:
|
||||
```
|
||||
[Reasoning]
|
||||
Consulting the Steward for resource planning...
|
||||
|
||||
The Steward's Analysis:
|
||||
- Request requires mathematical computation
|
||||
- Need to verify current information via web search
|
||||
- May benefit from Developer's code expertise
|
||||
|
||||
Recommended: calculator, web_search, The Developer
|
||||
|
||||
Proceeding with scoped resources...
|
||||
```
|
||||
|
||||
**Testing**:
|
||||
- Test streaming of Steward analysis
|
||||
- Verify formatting in Open WebUI
|
||||
- Test error handling if Steward fails
|
||||
|
||||
#### 5. Model Efficiency Optimization
|
||||
|
||||
**Purpose**: Ensure the base model stays loaded in VRAM
|
||||
|
||||
**Implementation Details**:
|
||||
|
||||
- **Shared Model Configuration**
|
||||
- Both Steward and Tatlock use `ollama:mistral-nemo` by default
|
||||
- Sequential calls (Steward → Tatlock) keep model hot
|
||||
- No reload delays between tiers
|
||||
|
||||
- **Performance Monitoring**
|
||||
- Log response times for Steward calls
|
||||
- Track total request latency (Steward + Tatlock)
|
||||
- Identify optimization opportunities
|
||||
|
||||
**Testing**:
|
||||
- Benchmark Steward → Tatlock call latency
|
||||
- Verify model stays loaded between calls
|
||||
- Test performance under load
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
#### Week 1-2: Foundation
|
||||
- [ ] Design and implement registry system
|
||||
- [ ] Create tool/agent metadata schemas
|
||||
- [ ] Build registry API with tests
|
||||
- [ ] Migrate existing tools to registry
|
||||
|
||||
#### Week 3-4: Steward Agent
|
||||
- [ ] Create Steward PydanticAI agent
|
||||
- [ ] Engineer system prompt for analysis
|
||||
- [ ] Implement structured recommendation output
|
||||
- [ ] Add registry query tool
|
||||
- [ ] Test with various request types
|
||||
|
||||
#### Week 5-6: Integration
|
||||
- [ ] Build request preprocessing pipeline
|
||||
- [ ] Implement note formatting
|
||||
- [ ] Integrate with Orchestrator
|
||||
- [ ] Add streaming transparency
|
||||
- [ ] Tool scoping for Tatlock
|
||||
|
||||
#### Week 7: Testing & Refinement
|
||||
- [ ] End-to-end integration tests
|
||||
- [ ] Performance optimization
|
||||
- [ ] Prompt refinement based on results
|
||||
- [ ] Documentation and examples
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- [x] **Steward analyzes incoming requests** using PydanticAI agent
|
||||
- [x] **Produces structured recommendations** (tools, agents, reasoning)
|
||||
- [x] **Recommendations formatted as prepended note** to Tatlock
|
||||
- [x] **Tool registry is queryable and extensible** via clean API
|
||||
- [x] **Steward output visible in reasoning stream** for transparency
|
||||
- [x] **Only recommended tools available** to Tatlock (scoped context)
|
||||
- [x] **Base model stays loaded** between Steward and Tatlock calls
|
||||
- [x] **Recommendations are accurate** (not over/under-inclusive)
|
||||
- [x] **Integration tests pass** for full Steward → Tatlock flow
|
||||
|
||||
### Status
|
||||
**✅ COMPLETE** (v0.2.5)
|
||||
|
||||
### Performance Targets
|
||||
|
||||
- **Steward Analysis Time**: < 2 seconds for typical requests
|
||||
- **Total Added Latency**: < 3 seconds including streaming
|
||||
- **Recommendation Accuracy**: > 90% relevance (manual evaluation)
|
||||
- **Model Reload Delay**: 0 seconds (model stays hot)
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
**Risk**: Steward recommendations too broad (defeats purpose)
|
||||
- Mitigation: Conservative prompt engineering, test with diverse requests, iterate
|
||||
|
||||
**Risk**: Added latency unacceptable to users
|
||||
- Mitigation: Stream Steward reasoning for transparency, optimize prompt, parallel processing where possible
|
||||
|
||||
**Risk**: Tool registry becomes unwieldy
|
||||
- Mitigation: Good categorization, semantic search (future), regular pruning
|
||||
|
||||
**Risk**: Steward and Tatlock models compete for VRAM
|
||||
- Mitigation: Use same base model, sequential calls, monitor memory
|
||||
|
||||
### Future Enhancements (Post-Phase 2)
|
||||
|
||||
- **Semantic Search**: Vector-based capability search instead of metadata lookup
|
||||
- **Learning from Usage**: Track which recommendations work well, adjust over time
|
||||
- **Confidence Scores**: Steward provides confidence for each recommendation
|
||||
- **Request Classification**: Cache classifications for similar requests
|
||||
- **Multi-Model Support**: Allow Steward to recommend specialized models for specific tasks
|
||||
|
||||
### Estimated Effort
|
||||
|
||||
**7-8 weeks** - Core intelligence routing with comprehensive implementation
|
||||
|
||||
### Why Second?
|
||||
|
||||
The Steward is the foundation of the household architecture. Without it, we'd need to expose all tools/agents to Tatlock, creating cognitive overload and poor decision-making. The Steward enables the focused expertise pattern that makes the whole system work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: The Butler - Tatlock Agent
|
||||
|
||||
**Goal**: Implement the second-tier coordinator with personality within the existing Orchestrator infrastructure
|
||||
|
||||
**Context**: The Orchestrator (FastAPI infrastructure) already exists. This phase implements the real Tatlock PydanticAI agent to replace the current mock agent.
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **Butler Agent (Tatlock)**
|
||||
- PydanticAI agent implementation within Orchestrator
|
||||
- Personality prompt engineering (witty British butler)
|
||||
- Tool calling framework
|
||||
- Multi-agent coordination logic
|
||||
|
||||
2. **Scoped Tool Access**
|
||||
- Filter tools based on Steward recommendations
|
||||
- Dynamic tool loading for Butler context
|
||||
- Tool execution framework
|
||||
- Result aggregation
|
||||
|
||||
3. **Real-Time Reasoning Output**
|
||||
- Stream all Butler activities to reasoning output
|
||||
- Tool call progress indicators
|
||||
- Expert agent consultation messages
|
||||
- Wait time transparency
|
||||
|
||||
### Success Criteria
|
||||
- [x] Tatlock receives enriched requests (user + Steward notes)
|
||||
- [x] Only recommended tools are available
|
||||
- [x] Tatlock coordinates multiple tool calls
|
||||
- [x] All actions streamed to reasoning output
|
||||
- [x] Responses have consistent personality
|
||||
- [x] Synthesizes multi-source results coherently
|
||||
|
||||
### Status
|
||||
**✅ COMPLETE** (v1.1.0)
|
||||
|
||||
### Estimated Effort
|
||||
**4-5 weeks** - Complex coordination logic
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Expert Household Staff - Core Agents
|
||||
|
||||
**Goal**: Implement the initial set of domain-specific expert agents
|
||||
|
||||
### Priority Expert Agents
|
||||
|
||||
1. **The Librarian** (Research & Knowledge Management) ✅ **COMPLETE** (v1.1.0)
|
||||
- Research assistance via library-desk HybridRAG
|
||||
- Wiki page management (search, create, update)
|
||||
- Semantic vector search
|
||||
- Knowledge graph queries
|
||||
- Dossier browsing
|
||||
|
||||
2. **The Biographer** (User Memory) ✅ **COMPLETE** (v1.2.0)
|
||||
- User profile management (name, location, timezone)
|
||||
- Preference storage (units, theme)
|
||||
- Semantic memory recall ("What car do I drive?")
|
||||
- Fact storage from conversations
|
||||
- Session context caching
|
||||
|
||||
3. **The Developer** (Software Development) 🔜 **Planned**
|
||||
- Code generation assistance
|
||||
- Debugging support
|
||||
- Documentation generation
|
||||
- Architecture guidance
|
||||
- *Rationale: Directly supports building the system itself*
|
||||
|
||||
4. **The Handyman** (System Maintenance) 🔜 **Planned**
|
||||
- System status queries
|
||||
- Log analysis
|
||||
- Basic troubleshooting
|
||||
- Infrastructure monitoring
|
||||
|
||||
5. **The Secretary** (Scheduling & Organization) 🔜 **Planned**
|
||||
- Calendar integration
|
||||
- Task management
|
||||
- Reminder system
|
||||
- Schedule conflict detection
|
||||
|
||||
6. **The Housekeeper** (Home Automation) 🔜 **Planned**
|
||||
- Home Assistant integration
|
||||
- Device control interface
|
||||
- Status queries
|
||||
- Automation triggers
|
||||
|
||||
### Each Agent Includes
|
||||
- Specialized prompt and personality
|
||||
- Domain-specific tools
|
||||
- MCP integration points (where applicable)
|
||||
- Integration with Butler orchestration
|
||||
|
||||
### Success Criteria
|
||||
- [x] Each agent implemented as separate module
|
||||
- [x] Agents callable via tool framework
|
||||
- [x] Agents use specialized prompts
|
||||
- [x] Results integrate cleanly with Butler
|
||||
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
||||
|
||||
### Status
|
||||
**🔶 PARTIAL** - Librarian and Biographer complete, others planned
|
||||
|
||||
### Estimated Effort
|
||||
**6-8 weeks** - Parallel development possible
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Persistence Layer - Database & Multi-Tenancy
|
||||
|
||||
**Goal**: Add persistent storage and multi-user support when needed
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **PostgreSQL Integration**
|
||||
- Docker compose configuration for PostgreSQL
|
||||
- Database schema design with tenant isolation
|
||||
- Alembic migrations setup
|
||||
- SQLAlchemy models
|
||||
|
||||
2. **Multi-Tenant Architecture**
|
||||
- Tenant identification middleware
|
||||
- Tenant-scoped database sessions
|
||||
- User authentication system (basic)
|
||||
- Per-tenant data isolation
|
||||
|
||||
3. **Core Data Models**
|
||||
- Users and tenants
|
||||
- Conversations and messages (migrate from in-memory)
|
||||
- Agent interactions log
|
||||
- System configuration and preferences
|
||||
|
||||
4. **Migration Strategy**
|
||||
- Gradual migration from in-memory to database
|
||||
- Backward compatibility during transition
|
||||
- Data export/import utilities
|
||||
|
||||
### Success Criteria
|
||||
- [ ] PostgreSQL container running
|
||||
- [ ] Multiple users can authenticate separately
|
||||
- [ ] Each user sees only their own data
|
||||
- [ ] Conversations persist across restarts
|
||||
- [ ] Database migrations work correctly
|
||||
- [ ] Tests verify tenant isolation
|
||||
|
||||
### Estimated Effort
|
||||
**3-4 weeks** - Data layer foundation
|
||||
|
||||
### Why Later?
|
||||
The core orchestration (Steward → Butler → Experts) can work entirely with in-memory state. We only need database persistence when we want conversations to survive restarts and multiple users to have isolated experiences.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Extended Services Integration
|
||||
|
||||
**Goal**: Connect to additional supporting services
|
||||
|
||||
### Services to Integrate
|
||||
|
||||
1. **Redis (Memory & Caching)** ✅ **COMPLETE** (v1.2.0)
|
||||
- Benchmark storage (db=1)
|
||||
- Memory cache for sessions (db=2)
|
||||
- 24h TTL for session context
|
||||
- Recent entities tracking
|
||||
|
||||
2. **Qdrant (Vector Storage)** ✅ **COMPLETE** (v1.2.0)
|
||||
- Per-user memory collections
|
||||
- 768-dim nomic-embed-text vectors
|
||||
- Semantic search for recall
|
||||
- Type-based filtering
|
||||
|
||||
3. **SearxNG (Web Search)** ✅ **COMPLETE** (v0.2.0)
|
||||
- Search tool integration
|
||||
- Result processing
|
||||
- Privacy-preserving queries
|
||||
|
||||
4. **library-desk (Research API)** ✅ **COMPLETE** (v1.1.0)
|
||||
- HybridRAG search
|
||||
- Wiki management
|
||||
- Knowledge graph queries
|
||||
|
||||
### Success Criteria
|
||||
- [x] Services communicate correctly
|
||||
- [x] Tatlock can invoke web search
|
||||
- [x] Redis used for session data
|
||||
- [x] Qdrant stores user memories
|
||||
- [x] Ollama serves the base model
|
||||
|
||||
### Status
|
||||
**✅ COMPLETE** - All core services integrated
|
||||
|
||||
### Estimated Effort
|
||||
**3-4 weeks** - Infrastructure setup
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: MCP (Model Context Protocol) Integration
|
||||
|
||||
**Goal**: Enable rich tool integrations via MCP
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **MCP Server Framework**
|
||||
- MCP server implementation
|
||||
- Tool registration via MCP
|
||||
- Schema validation
|
||||
- Error handling
|
||||
|
||||
2. **MCP Client in Agents**
|
||||
- PydanticAI MCP integration
|
||||
- Tool discovery from MCP servers
|
||||
- Dynamic tool loading
|
||||
- Result processing
|
||||
|
||||
3. **Initial MCP Tools**
|
||||
- File system operations
|
||||
- Database queries
|
||||
- API integrations
|
||||
- System commands
|
||||
|
||||
### Success Criteria
|
||||
- [ ] MCP server running
|
||||
- [ ] Tools exposed via MCP protocol
|
||||
- [ ] Agents can discover and use MCP tools
|
||||
- [ ] New tools addable without code changes
|
||||
- [ ] MCP tools visible in Steward recommendations
|
||||
|
||||
### Estimated Effort
|
||||
**3-4 weeks** - Standards-based integration
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Advanced Memory & Context
|
||||
|
||||
**Goal**: Implement sophisticated memory and context management
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **Long-Term Memory** ✅ **COMPLETE** (v1.2.0 - Phase F)
|
||||
- Memory service for direct key-based access
|
||||
- Qdrant vector storage for semantic recall
|
||||
- Embedding via nomic-embed-text
|
||||
- The Biographer agent for memory management
|
||||
|
||||
2. **Session Memory** ✅ **COMPLETE** (v1.2.0)
|
||||
- Redis session cache with 24h TTL
|
||||
- Recent entities tracking
|
||||
- Conversation context preservation
|
||||
- Multi-tenancy via ContextVar
|
||||
|
||||
3. **Steward Integration** ✅ **COMPLETE** (v1.2.0)
|
||||
- Memory pre-fetch during request analysis
|
||||
- Profile/preferences included in context
|
||||
- Keyword-based context determination
|
||||
|
||||
4. **Context Management** 🔜 **Future**
|
||||
- Smart context window trimming
|
||||
- Conversation branching
|
||||
- Topic tracking
|
||||
- Memory retrieval integration
|
||||
|
||||
5. **Personalization** 🔜 **Future**
|
||||
- User preference learning
|
||||
- Interaction pattern analysis
|
||||
- Adaptive responses
|
||||
- Custom agent personalities per user
|
||||
|
||||
### Success Criteria
|
||||
- [x] User facts stored in Qdrant with semantic search
|
||||
- [x] Profile and preferences accessible via memory_service
|
||||
- [x] Session context cached in Redis
|
||||
- [x] User preferences affect responses (via Steward pre-fetch)
|
||||
- [ ] Conversations automatically embedded to Qdrant
|
||||
- [ ] Memory improves over time (learning from interactions)
|
||||
|
||||
### Status
|
||||
**🔶 PARTIAL** - Core memory system complete, advanced features planned
|
||||
|
||||
### Estimated Effort
|
||||
**4-5 weeks** - AI/ML heavy (remaining work)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Extended Household Staff
|
||||
|
||||
**Goal**: Add specialized agents for additional domains
|
||||
|
||||
### Future Agents
|
||||
|
||||
1. **The Librarian** (Knowledge Management)
|
||||
- Personal documentation indexing
|
||||
- Research assistance
|
||||
- Knowledge base queries
|
||||
- Reference management
|
||||
|
||||
2. **The Accountant** (Financial Tracking)
|
||||
- Expense tracking
|
||||
- Budget monitoring
|
||||
- Financial reports
|
||||
- Transaction categorization
|
||||
|
||||
3. **The Chef** (Meal Planning)
|
||||
- Recipe management
|
||||
- Meal planning
|
||||
- Nutrition tracking
|
||||
- Grocery lists
|
||||
|
||||
4. **Others as Needed**
|
||||
- Domain-specific as requirements emerge
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Each new agent follows household pattern
|
||||
- [ ] Integrates with Steward/Butler flow
|
||||
- [ ] Has appropriate specialized tools
|
||||
- [ ] Documented in PHILOSOPHY.md updates
|
||||
|
||||
### Estimated Effort
|
||||
**Ongoing** - Add as needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: User Experience Refinement
|
||||
|
||||
**Goal**: Polish the interaction experience
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **Personality Tuning**
|
||||
- Refine Tatlock's wit and tone
|
||||
- Consistent household character
|
||||
- Cultural references appropriate
|
||||
- Humor that doesn't annoy
|
||||
|
||||
2. **Transparency Improvements**
|
||||
- Better progress indicators
|
||||
- Clearer reasoning explanations
|
||||
- Informative wait messages
|
||||
- Error message clarity
|
||||
|
||||
3. **Performance Optimization**
|
||||
- Response time improvements
|
||||
- Model loading optimization
|
||||
- Caching strategies
|
||||
- Streaming smoothness
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Users find Tatlock engaging
|
||||
- [ ] Wait times feel reasonable
|
||||
- [ ] Errors are understandable
|
||||
- [ ] System feels responsive
|
||||
|
||||
### Estimated Effort
|
||||
**Ongoing** - Continuous improvement
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Production Hardening
|
||||
|
||||
**Goal**: Make the system production-ready for homelab deployment
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **Deployment**
|
||||
- Complete docker-compose stack
|
||||
- Environment configuration
|
||||
- Backup strategies
|
||||
- Update procedures
|
||||
|
||||
2. **Monitoring**
|
||||
- Health checks
|
||||
- Performance metrics
|
||||
- Error tracking
|
||||
- Usage analytics
|
||||
|
||||
3. **Security**
|
||||
- Authentication hardening
|
||||
- Rate limiting
|
||||
- Input validation
|
||||
- Audit logging
|
||||
|
||||
4. **Documentation**
|
||||
- Installation guide
|
||||
- Configuration reference
|
||||
- Troubleshooting guide
|
||||
- Architecture documentation
|
||||
|
||||
### Success Criteria
|
||||
- [ ] One-command deployment
|
||||
- [ ] System health is monitorable
|
||||
- [ ] Secure for homelab use
|
||||
- [ ] Well documented
|
||||
|
||||
### Estimated Effort
|
||||
**3-4 weeks** - Production polish
|
||||
|
||||
---
|
||||
|
||||
## Dependencies Between Phases
|
||||
|
||||
```
|
||||
Phase 1 (Ollama + PydanticAI) ← Foundation for all AI
|
||||
↓
|
||||
Phase 2 (Steward)
|
||||
↓
|
||||
Phase 3 (Butler/Tatlock)
|
||||
↓
|
||||
Phase 4 (Expert Agents) ← Phase 7 (MCP) can enhance
|
||||
↓
|
||||
Phase 5 (Database/Multi-Tenancy) ← Can be deferred
|
||||
↓
|
||||
Phase 6 (Extended Services) → Phase 8 (Advanced Memory)
|
||||
↓
|
||||
Phase 9 (Extended Staff) → Phase 10 (UX) → Phase 11 (Production)
|
||||
```
|
||||
|
||||
**Critical Path**: Phases 1 → 2 → 3 → 4 must be sequential
|
||||
**Can Be Deferred**: Phase 5 (Database) until you need persistence
|
||||
**Parallel Opportunities**: Phase 6 and 7 can overlap; Phase 9 and 10 ongoing
|
||||
|
||||
---
|
||||
|
||||
## Overall Timeline Estimate
|
||||
|
||||
**Minimum Viable Household** (Phases 1-4): **15-20 weeks**
|
||||
- Working Steward → Butler → Expert Agents with real LLM
|
||||
- In-memory state (no persistence needed yet)
|
||||
- Core household functional
|
||||
|
||||
**With Persistence** (Phases 1-5): **18-24 weeks**
|
||||
- Add database and multi-tenancy
|
||||
- Conversations survive restarts
|
||||
- Multiple users supported
|
||||
|
||||
**Full-Featured System** (Phases 1-9): **35-45 weeks**
|
||||
- All services integrated
|
||||
- Advanced memory and context
|
||||
- Extended household staff
|
||||
|
||||
**Production-Ready** (All phases): **40-50 weeks**
|
||||
- Polished UX
|
||||
- Hardened for homelab deployment
|
||||
- Fully documented
|
||||
|
||||
*Note: Timeline assumes consistent part-time development effort*
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical
|
||||
- System implements PHILOSOPHY.md patterns
|
||||
- All household roles functional
|
||||
- Multi-tenant isolation verified
|
||||
- Real-time reasoning transparency working
|
||||
- MCP integration complete
|
||||
|
||||
### User Experience
|
||||
- Tatlock feels like interacting with a butler
|
||||
- Wait times are transparent and acceptable
|
||||
- Expert agents provide value in their domains
|
||||
- System is reliable and trustworthy
|
||||
|
||||
### Architecture
|
||||
- Clean separation between household roles
|
||||
- Easy to add new agents/tools
|
||||
- Model efficiency (base model stays loaded)
|
||||
- Scales to household + friends usage
|
||||
|
||||
---
|
||||
|
||||
## Risk Management
|
||||
|
||||
### High Risk Items
|
||||
1. **PydanticAI + Ollama integration complexity**
|
||||
- Mitigation: Prototype early, iterate on connection layer
|
||||
|
||||
2. **Multi-agent coordination complexity**
|
||||
- Mitigation: Start simple, add coordination gradually
|
||||
|
||||
3. **Model performance on homelab hardware**
|
||||
- Mitigation: Model selection, quantization, optimization
|
||||
|
||||
4. **Prompt engineering for personality consistency**
|
||||
- Mitigation: Extensive testing, user feedback, iteration
|
||||
|
||||
### Medium Risk Items
|
||||
- MCP protocol adoption and tooling maturity
|
||||
- Vector embedding quality for memory
|
||||
- Home automation integration variability
|
||||
- User authentication security
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Priority**: Implement The Developer agent for code assistance
|
||||
2. **Integration**: Add Home Assistant integration for The Housekeeper
|
||||
3. **Calendar**: Integrate scheduling service for The Secretary
|
||||
4. **Ongoing**: Add more household staff as needed
|
||||
|
||||
---
|
||||
|
||||
**Document Status**: Active planning document
|
||||
**Created**: 2025-12-06
|
||||
**Last Updated**: 2025-12-13
|
||||
@@ -0,0 +1,48 @@
|
||||
.PHONY: help setup run test test-unit test-integration lint typecheck clean
|
||||
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
PYTEST := $(VENV)/bin/pytest
|
||||
RUFF := $(VENV)/bin/ruff
|
||||
MYPY := $(VENV)/bin/mypy
|
||||
UVICORN := $(VENV)/bin/uvicorn
|
||||
|
||||
HOST := 0.0.0.0
|
||||
PORT := 8777
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
setup: ## Create venv and install all dependencies
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -e ".[dev]"
|
||||
|
||||
run: ## Start the development server on port 8777
|
||||
@mkdir -p build/logs
|
||||
@if lsof -Pi :$(PORT) -sTCP:LISTEN -t >/dev/null 2>&1; then \
|
||||
echo "Error: Port $(PORT) is already in use"; \
|
||||
echo "Run: lsof -i :$(PORT) to see what's using it"; \
|
||||
exit 1; \
|
||||
fi
|
||||
$(UVICORN) src.main:app --reload --host $(HOST) --port $(PORT) 2>&1 | tee build/logs/server.log
|
||||
|
||||
test: ## Run unit tests (no external services needed)
|
||||
$(PYTEST) --ignore=tests/e2e --ignore=tests/integration
|
||||
|
||||
test-unit: test ## Alias for test
|
||||
|
||||
test-integration: ## Run integration tests (needs Claude/Ollama)
|
||||
$(PYTEST) tests/agents/test_tatlock_agent.py -v
|
||||
|
||||
lint: ## Run ruff linter and formatter check
|
||||
$(RUFF) check src tests
|
||||
$(RUFF) format --check src tests
|
||||
|
||||
typecheck: ## Run mypy type checking
|
||||
$(MYPY) src
|
||||
|
||||
clean: ## Remove build artifacts, caches, and coverage reports
|
||||
rm -rf .cache build
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tatlock - Your Homelab Butler
|
||||
|
||||
> **📖 For the complete system vision and architectural philosophy, see [PHILOSOPHY.md](PHILOSOPHY.md)**
|
||||
> **📖 For the complete system vision and architectural philosophy, see [docs/philosophy.md](docs/philosophy.md)**
|
||||
|
||||
A privacy-first, offline-capable personal assistant system that coordinates specialized AI agents to help with research, development, home automation, and daily organization.
|
||||
|
||||
@@ -89,12 +89,8 @@ A privacy-first, offline-capable personal assistant system that coordinates spec
|
||||
git clone https://git.schweitz.net/jpmschweitzer/tatlock.git
|
||||
cd tatlock
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
make setup
|
||||
```
|
||||
|
||||
### Run the Server
|
||||
@@ -396,8 +392,7 @@ tatlock/
|
||||
│ │ └── multi_tenancy.py # User isolation utilities
|
||||
│ └── main.py # Application entry point
|
||||
├── tests/ # Comprehensive test suite
|
||||
├── PHILOSOPHY.md # System vision and architecture
|
||||
├── IMPLEMENTATION_ROADMAP.md # Development phases
|
||||
├── docs/ # Project documentation
|
||||
├── CHANGELOG.md # Version history
|
||||
└── README.md # This file
|
||||
```
|
||||
@@ -416,8 +411,8 @@ For LLM agent development guidelines and architectural decisions, see [AGENTS.md
|
||||
|
||||
## Documentation
|
||||
|
||||
- **System Philosophy**: [PHILOSOPHY.md](PHILOSOPHY.md) - Vision, goals, and architectural patterns
|
||||
- **User Guide**: This file - Installation, usage, and examples
|
||||
- **System Philosophy**: [docs/philosophy.md](docs/philosophy.md) - Vision, goals, and architectural patterns
|
||||
- **Development Roadmap**: [docs/roadmap.md](docs/roadmap.md) - Open work and planned phases
|
||||
- **Developer Guidelines**: [AGENTS.md](AGENTS.md) - LLM agent development patterns
|
||||
- **Version History**: [CHANGELOG.md](CHANGELOG.md) - Changes and releases
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Claude Integration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Tatlock uses a bidirectional Claude architecture:
|
||||
- **Scenario A**: Tatlock powered by Claude backend (with Ollama fallback) — **COMPLETE**
|
||||
- **Scenario B**: Tatlock exposed as MCP server for external Claude instances — **OPEN**
|
||||
- **Scenario C**: Offline operation via Ollama — **COMPLETE**
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (Expose Tools to Claude) — NOT STARTED
|
||||
|
||||
Create an MCP server that exposes Tatlock's household tools to external Claude instances.
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
src/mcp/
|
||||
├── __init__.py
|
||||
├── server.py # MCP server using mcp Python SDK
|
||||
├── tool_adapters.py # Convert PydanticAI tools → MCP schemas
|
||||
├── auth.py # API key authentication
|
||||
└── transport.py # Streamable HTTP transport
|
||||
```
|
||||
|
||||
### Docker Stack Addition
|
||||
|
||||
```yaml
|
||||
tatlock-mcp:
|
||||
image: git.schweitz.internal/jpmschweitzer/tatlock:latest
|
||||
command: ["python", "-m", "src.mcp.server"]
|
||||
ports:
|
||||
- "8002:8002"
|
||||
environment:
|
||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||
networks:
|
||||
- docker-dataplane
|
||||
```
|
||||
|
||||
### Claude Desktop Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"tatlock": {
|
||||
"command": "npx",
|
||||
"args": ["mcp-remote", "https://mcp.schweitz.net/sse", "--header", "Authorization: Bearer ${MCP_AUTH_TOKEN}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Create `src/mcp/` module
|
||||
- [ ] Tool adapters (PydanticAI → MCP schema)
|
||||
- [ ] Authentication middleware
|
||||
- [ ] Streamable HTTP transport
|
||||
- [ ] Docker stack configuration
|
||||
|
||||
---
|
||||
|
||||
## Future Phases
|
||||
|
||||
- **LiteLLM Gateway** — Unified endpoint for all models, config-driven routing
|
||||
- **Multi-Provider** — Add OpenAI, Vertex AI, etc.
|
||||
- **Smart Routing** — Context-aware model selection, cost ceiling enforcement
|
||||
|
||||
---
|
||||
|
||||
## Offline Behavior
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| No API key | Use Ollama exclusively |
|
||||
| API unreachable | Use Ollama, log warning |
|
||||
| API rate limited | Fallback to Ollama |
|
||||
|
||||
| Aspect | Claude | Ollama |
|
||||
|--------|--------|--------|
|
||||
| Context | 200k tokens | ~8k tokens |
|
||||
| Latency | 1-3s (network) | 0.5-1s (local) |
|
||||
| Personality | Preserved | Preserved |
|
||||
| Tools | All work | All work |
|
||||
| Cost | API charges | Free |
|
||||
|
||||
---
|
||||
|
||||
## Related Repo Handovers
|
||||
|
||||
Handover documents created in each repo: `PROJECT_CLAUDIFICATION_HANDOVER.md`
|
||||
|
||||
### Open Items
|
||||
|
||||
- **library-desk**: Review HybridRAG response size limits, smart_create endpoint, response formats
|
||||
- **core-api**: Review list_devices response format, error messages, rate limiting
|
||||
- **portainer-core**: Update stack with new env vars, configure secrets, update CONTAINERS.md
|
||||
- **webber**: Review content truncation limits, extraction quality
|
||||
- **tatlock-ui**: Test streaming with Claude backend, conversation history, tool call display
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# Tatlock Implementation Roadmap
|
||||
|
||||
> **Reference**: See [philosophy.md](philosophy.md) for the target architecture and vision
|
||||
|
||||
This document tracks open/planned work. Completed phases have been removed.
|
||||
|
||||
## Current State (v2.0.5)
|
||||
|
||||
**What we have**:
|
||||
- OpenAI-compatible API (Responses API + Chat Completions)
|
||||
- Two-tier architecture (Steward → Tatlock)
|
||||
- Household staff: Tatlock (Butler), Steward, Librarian, Biographer
|
||||
- Core tools: Calculator, Date/Time, Web search (SearXNG)
|
||||
- Memory system: Qdrant (vector), Redis (session cache), multi-tenancy via ContextVar
|
||||
- Dual backend: Claude (preferred) + Ollama (fallback)
|
||||
- 439 tests with good coverage
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Expert Household Staff — Remaining Agents
|
||||
|
||||
**Goal**: Implement remaining domain-specific expert agents
|
||||
|
||||
### Planned Agents
|
||||
|
||||
1. **The Developer** (Software Development)
|
||||
- Code generation assistance
|
||||
- Debugging support
|
||||
- Documentation generation
|
||||
- Architecture guidance
|
||||
|
||||
2. **The Handyman** (System Maintenance)
|
||||
- System status queries
|
||||
- Log analysis
|
||||
- Basic troubleshooting
|
||||
- Infrastructure monitoring
|
||||
|
||||
3. **The Secretary** (Scheduling & Organization)
|
||||
- Calendar integration
|
||||
- Task management
|
||||
- Reminder system
|
||||
- Schedule conflict detection
|
||||
|
||||
4. **The Housekeeper** (Home Automation)
|
||||
- Home Assistant integration
|
||||
- Device control interface
|
||||
- Status queries
|
||||
- Automation triggers
|
||||
|
||||
### Each Agent Includes
|
||||
- Specialized prompt and personality
|
||||
- Domain-specific tools
|
||||
- MCP integration points (where applicable)
|
||||
- Integration with Butler orchestration
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Each agent implemented as separate module
|
||||
- [ ] Agents callable via tool framework
|
||||
- [ ] Can invoke specialized models (e.g., Codestral for Developer)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Persistence Layer — Database & Multi-Tenancy
|
||||
|
||||
**Goal**: Add persistent storage and multi-user support
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **PostgreSQL Integration**
|
||||
- Docker compose configuration
|
||||
- Database schema with tenant isolation
|
||||
- Alembic migrations
|
||||
- SQLAlchemy models
|
||||
|
||||
2. **Multi-Tenant Architecture**
|
||||
- Tenant identification middleware
|
||||
- Tenant-scoped database sessions
|
||||
- User authentication system
|
||||
- Per-tenant data isolation
|
||||
|
||||
3. **Core Data Models**
|
||||
- Users and tenants
|
||||
- Conversations and messages (migrate from in-memory)
|
||||
- Agent interactions log
|
||||
- System configuration and preferences
|
||||
|
||||
### Success Criteria
|
||||
- [ ] PostgreSQL container running
|
||||
- [ ] Multiple users authenticate separately
|
||||
- [ ] Each user sees only their own data
|
||||
- [ ] Conversations persist across restarts
|
||||
- [ ] Database migrations work correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: MCP (Model Context Protocol) Integration
|
||||
|
||||
**Goal**: Enable rich tool integrations via MCP
|
||||
|
||||
See also [claude-integration.md](claude-integration.md) for MCP server implementation details.
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. **MCP Server Framework**
|
||||
- MCP server implementation
|
||||
- Tool registration via MCP
|
||||
- Schema validation
|
||||
- Error handling
|
||||
|
||||
2. **MCP Client in Agents**
|
||||
- PydanticAI MCP integration
|
||||
- Tool discovery from MCP servers
|
||||
- Dynamic tool loading
|
||||
|
||||
3. **Initial MCP Tools**
|
||||
- File system operations
|
||||
- Database queries
|
||||
- API integrations
|
||||
- System commands
|
||||
|
||||
### Success Criteria
|
||||
- [ ] MCP server running
|
||||
- [ ] Tools exposed via MCP protocol
|
||||
- [ ] Agents can discover and use MCP tools
|
||||
- [ ] New tools addable without code changes
|
||||
- [ ] MCP tools visible in Steward recommendations
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Advanced Memory & Context — Remaining Work
|
||||
|
||||
**Goal**: Implement sophisticated context management and personalization
|
||||
|
||||
### Open Deliverables
|
||||
|
||||
1. **Context Management**
|
||||
- Smart context window trimming
|
||||
- Conversation branching
|
||||
- Topic tracking
|
||||
|
||||
2. **Personalization**
|
||||
- User preference learning
|
||||
- Interaction pattern analysis
|
||||
- Adaptive responses
|
||||
- Custom agent personalities per user
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Conversations automatically embedded to Qdrant
|
||||
- [ ] Memory improves over time (learning from interactions)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Extended Household Staff
|
||||
|
||||
**Goal**: Add specialized agents for additional domains
|
||||
|
||||
### Future Agents
|
||||
- **The Accountant** — Expense tracking, budgets, financial reports
|
||||
- **The Chef** — Meal planning, recipes, nutrition tracking
|
||||
- Others as needs emerge
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: User Experience Refinement
|
||||
|
||||
**Goal**: Polish the interaction experience
|
||||
|
||||
- Personality tuning and consistency
|
||||
- Better progress indicators
|
||||
- Response time improvements
|
||||
- Streaming smoothness
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Production Hardening
|
||||
|
||||
**Goal**: Make the system production-ready for homelab deployment
|
||||
|
||||
- Complete docker-compose stack
|
||||
- Health checks and monitoring
|
||||
- Authentication hardening and rate limiting
|
||||
- Installation and troubleshooting documentation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
Phase 4 (Remaining Agents)
|
||||
↓
|
||||
Phase 5 (Database/Multi-Tenancy) ← Can be deferred
|
||||
↓
|
||||
Phase 7 (MCP) → Phase 8 (Advanced Memory)
|
||||
↓
|
||||
Phase 9 (Extended Staff) → Phase 10 (UX) → Phase 11 (Production)
|
||||
```
|
||||
|
||||
**Can Be Deferred**: Phase 5 until you need persistence
|
||||
**Parallel Opportunities**: Phases 7 and 8 can overlap; 9 and 10 ongoing
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement The Developer agent for code assistance
|
||||
2. Add Home Assistant integration for The Housekeeper
|
||||
3. Integrate scheduling service for The Secretary
|
||||
4. MCP server for external Claude access
|
||||
+58
-3
@@ -4,17 +4,67 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tatlock"
|
||||
version = "1.10.0"
|
||||
version = "2.1.0"
|
||||
description = "OpenAI-compatible API with Ollama backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"fastapi>=0.123,<0.124",
|
||||
"uvicorn[standard]>=0.38,<0.39",
|
||||
"pydantic>=2.11,<2.13",
|
||||
"pydantic-settings>=2.12,<2.13",
|
||||
"pydantic-ai-slim[openai,anthropic]>=1.27,<1.28",
|
||||
"httpx>=0.28,<0.29",
|
||||
"sse-starlette>=3.0,<3.1",
|
||||
"python-dotenv>=1.2,<1.3",
|
||||
"starlette>=0.45,<0.46",
|
||||
"redis[hiredis]>=5.2,<6.0",
|
||||
"qdrant-client>=1.12,<2.0",
|
||||
"structlog>=24.1,<25.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3,<8.4",
|
||||
"pytest-asyncio>=0.25,<0.26",
|
||||
"pytest-cov>=6.0,<6.1",
|
||||
"pytest-mock>=3.14,<3.15",
|
||||
"ruff>=0.8,<0.9",
|
||||
"mypy>=1.14,<1.15",
|
||||
"faker>=34.0,<35.0",
|
||||
"coverage[toml]>=7.7,<7.8",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
cache_dir = ".cache/pytest"
|
||||
markers = [
|
||||
"unit: Unit tests",
|
||||
"integration: Integration tests",
|
||||
"slow: Slow running tests",
|
||||
]
|
||||
addopts = [
|
||||
"--verbose",
|
||||
"--strict-markers",
|
||||
"--tb=short",
|
||||
"--cov=src",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html:build/coverage/html",
|
||||
"--cov-report=xml:build/coverage/coverage.xml",
|
||||
"--cov-branch",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
branch = true
|
||||
data_file = "build/coverage/.coverage"
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/__pycache__/*",
|
||||
@@ -37,11 +87,15 @@ exclude_lines = [
|
||||
]
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "htmlcov"
|
||||
directory = "build/coverage/html"
|
||||
|
||||
[tool.coverage.xml]
|
||||
output = "build/coverage/coverage.xml"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
cache-dir = ".cache/ruff"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
@@ -64,6 +118,7 @@ ignore = [
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
cache_dir = ".cache/mypy"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
slow: Slow running tests
|
||||
|
||||
# Coverage options (overridden by pyproject.toml)
|
||||
addopts =
|
||||
--verbose
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=src
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-branch
|
||||
|
||||
# Ignore warnings from dependencies
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -1,25 +0,0 @@
|
||||
# Development and Testing Dependencies
|
||||
# Install with: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
# Testing Framework
|
||||
# Latest pytest with async support
|
||||
pytest>=8.3,<8.4
|
||||
pytest-asyncio>=0.25,<0.26
|
||||
pytest-cov>=6.0,<6.1
|
||||
|
||||
# Test client for FastAPI
|
||||
httpx>=0.28,<0.29 # Already in requirements.txt but needed for test client
|
||||
|
||||
# Code Quality
|
||||
# Linting and formatting
|
||||
ruff>=0.8,<0.9
|
||||
|
||||
# Type checking
|
||||
mypy>=1.14,<1.15
|
||||
|
||||
# Testing utilities
|
||||
pytest-mock>=3.14,<3.15
|
||||
faker>=34.0,<35.0
|
||||
|
||||
# Coverage reporting
|
||||
coverage[toml]>=7.7,<7.8
|
||||
@@ -1,61 +0,0 @@
|
||||
# Core FastAPI framework and server
|
||||
# FastAPI: Modern, fast web framework for building APIs
|
||||
# Latest: 0.123.9 (Dec 4, 2025) - No known CVEs
|
||||
fastapi>=0.123,<0.124
|
||||
|
||||
# ASGI server for running FastAPI
|
||||
# Latest: 0.38.0 (Oct 18, 2025) - No known CVEs
|
||||
# Note: Old versions had CVE-2020-7694/7695, but 0.38.0 is secure
|
||||
uvicorn[standard]>=0.38,<0.39
|
||||
|
||||
# Additional dependencies
|
||||
# Pydantic for data validation (comes with pydantic-ai but pinning explicitly)
|
||||
# Updated to >=2.11 due to ag-ui-protocol dependency requirement
|
||||
# Latest: 2.12.4 (Nov 5, 2025) - No known CVEs
|
||||
pydantic>=2.11,<2.13
|
||||
|
||||
# Pydantic settings for configuration management
|
||||
# Required explicitly since pydantic-ai-slim doesn't include it
|
||||
# Latest: 2.12.0 (Dec 2025) - No known CVEs
|
||||
pydantic-settings>=2.12,<2.13
|
||||
|
||||
# AI/LLM integration
|
||||
# PydanticAI: Agent framework for using Pydantic with LLMs
|
||||
# Using slim version with only openai extra (Ollama uses OpenAI-compatible API)
|
||||
# This avoids installing SDKs for anthropic, cohere, google, groq, huggingface, etc.
|
||||
# See DEPENDENCY_SLIM.md for rollback instructions if this breaks
|
||||
pydantic-ai-slim[openai]>=1.27,<1.28
|
||||
|
||||
# HTTP client for Ollama communication
|
||||
# Latest: 0.28.1 - No known CVEs
|
||||
httpx>=0.28,<0.29
|
||||
|
||||
# Server-Sent Events for streaming responses
|
||||
# Required for OpenAI-compatible streaming endpoints
|
||||
# Latest: 3.0.2 (Oct 30, 2025) - No known CVEs
|
||||
sse-starlette>=3.0,<3.1
|
||||
|
||||
# Configuration management
|
||||
# Latest: 1.2.1 (Oct 26, 2025) - No known CVEs
|
||||
python-dotenv>=1.2,<1.3
|
||||
|
||||
# ASGI toolkit (dependency of FastAPI, pinning for security)
|
||||
starlette>=0.45,<0.46
|
||||
|
||||
# Redis for performance benchmarking and caching
|
||||
# Latest: 5.2.1 (Dec 5, 2025) - No known CVEs
|
||||
# hiredis: C parser for better performance
|
||||
redis[hiredis]>=5.2,<6.0
|
||||
|
||||
# Qdrant vector database client for memory storage
|
||||
# Latest: 1.12.1 (Dec 2025) - No known CVEs
|
||||
qdrant-client>=1.12,<2.0
|
||||
|
||||
# Structured logging for observability
|
||||
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
||||
structlog>=24.1,<25.0
|
||||
|
||||
# Note on version locking strategy:
|
||||
# Using >=X.Y,<X.(Y+1) format to lock to minor versions
|
||||
# This protects against supply chain attacks while allowing patch updates
|
||||
# Update regularly and review changelogs before upgrading minor versions
|
||||
@@ -102,16 +102,10 @@ _biographer_agent: Optional[Agent[None, str]] = None
|
||||
|
||||
def _create_biographer_agent() -> Agent[None, str]:
|
||||
"""Create The Biographer PydanticAI agent."""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
|
||||
# Create Ollama model with sanitized provider
|
||||
# (fixes 'content: null' issue with tool calls)
|
||||
model = OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
provider=get_ollama_provider(),
|
||||
)
|
||||
# Get best available model (Claude if available, else Ollama)
|
||||
model = get_model()
|
||||
|
||||
agent: Agent[None, str] = Agent(
|
||||
model=model,
|
||||
@@ -131,9 +125,12 @@ def _create_biographer_agent() -> Agent[None, str]:
|
||||
# Register management tools
|
||||
agent.tool_plain(forget_memory)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"biographer_agent_created",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
tool_count=6,
|
||||
)
|
||||
|
||||
|
||||
@@ -103,16 +103,10 @@ _housekeeper_agent: Optional[Agent[None, str]] = None
|
||||
|
||||
def _create_housekeeper_agent() -> Agent[None, str]:
|
||||
"""Create the Housekeeper PydanticAI agent."""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
|
||||
# Create Ollama model with sanitized provider
|
||||
# (fixes 'content: null' issue with tool calls)
|
||||
model = OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
provider=get_ollama_provider(),
|
||||
)
|
||||
# Get best available model (Claude if available, else Ollama)
|
||||
model = get_model()
|
||||
|
||||
agent: Agent[None, str] = Agent(
|
||||
model=model,
|
||||
@@ -145,9 +139,12 @@ def _create_housekeeper_agent() -> Agent[None, str]:
|
||||
# Register history tools
|
||||
agent.tool_plain(get_history)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"housekeeper_agent_created",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
tool_count=13,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ Your role is to help users find, understand, synthesize, and manage information
|
||||
- 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
|
||||
- Paperless documents (📑) - indexed PDFs, scanned documents, invoices, receipts from the user's document archive
|
||||
- Volatile cache (⚡) - pre-fetched real-time data for user-relevant locations and items:
|
||||
- weather/forecast: conditions and forecasts for user's configured cities
|
||||
- news: headlines from user's preferred sources
|
||||
- stock/crypto: quotes for user's watched symbols
|
||||
- sun/air_quality: data for user's locations
|
||||
- Note: volatile data may not exist for arbitrary queries - falls back to web search
|
||||
- Web search (SearXNG) for current information not available in cache
|
||||
|
||||
## Your Personality
|
||||
- Scholarly and thorough in your research
|
||||
@@ -60,7 +67,13 @@ Your role is to help users find, understand, synthesize, and manage information
|
||||
- Use for: comparing multiple sources, gathering info from several pages
|
||||
|
||||
### Internal Research Tools
|
||||
- **hybrid_search**: Your primary research tool - searches wiki, graph, and web at once
|
||||
- **hybrid_search**: Your primary research tool - searches ALL sources at once:
|
||||
- Wiki pages (vector similarity)
|
||||
- Knowledge graph (entity relationships)
|
||||
- Paperless documents (📑 indexed PDFs, scans)
|
||||
- Volatile cache (⚡ weather, news, stocks - when available)
|
||||
- Web search (current information)
|
||||
Results are fused and re-ranked by relevance. Volatile data gets priority when fresh.
|
||||
- **search_wiki**: Find specific wiki pages by keyword
|
||||
- **semantic_search**: Find conceptually similar content
|
||||
- **explore_knowledge_graph** / **find_related_entities**: Discover connections
|
||||
@@ -130,16 +143,10 @@ _librarian_agent: Optional[Agent[None, str]] = None
|
||||
|
||||
def _create_librarian_agent() -> Agent[None, str]:
|
||||
"""Create the Librarian PydanticAI agent."""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
|
||||
# Create Ollama model with sanitized provider
|
||||
# (fixes 'content: null' issue with tool calls)
|
||||
model = OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
provider=get_ollama_provider(),
|
||||
)
|
||||
# Get best available model (Claude if available, else Ollama)
|
||||
model = get_model()
|
||||
|
||||
agent: Agent[None, str] = Agent(
|
||||
model=model,
|
||||
@@ -169,9 +176,12 @@ def _create_librarian_agent() -> Agent[None, str]:
|
||||
agent.tool_plain(update_wiki_page)
|
||||
agent.tool_plain(smart_create_wiki_page)
|
||||
|
||||
from src.anthropic.model_selector import get_model_info
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"librarian_agent_created",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
tool_count=14, # 7 research + 3 web + 1 wiki read + 3 wiki write
|
||||
)
|
||||
|
||||
|
||||
@@ -225,18 +225,22 @@ class LibraryDeskClient:
|
||||
vector_limit: int = 10,
|
||||
graph_limit: int = 10,
|
||||
web_limit: int = 5,
|
||||
document_limit: int = 5,
|
||||
volatile_limit: int = 3,
|
||||
enable_reranking: bool = True,
|
||||
final_result_count: int = 10,
|
||||
) -> HybridRAGResponse:
|
||||
"""
|
||||
Execute HybridRAG search combining vector, graph, and web results.
|
||||
Execute HybridRAG search combining vector, graph, documents, volatile, and web.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
user: User identifier for multi-tenancy (defaults to request context)
|
||||
vector_limit: Max results from vector search
|
||||
vector_limit: Max results from vector search (wiki pages)
|
||||
graph_limit: Max results from graph search
|
||||
web_limit: Max results from web search
|
||||
web_limit: Max results from web search (0 to disable)
|
||||
document_limit: Max results from Paperless documents (0 to disable)
|
||||
volatile_limit: Max results from volatile cache (0 to disable)
|
||||
enable_reranking: Whether to rerank with LLM
|
||||
final_result_count: Number of final results after fusion
|
||||
|
||||
@@ -252,6 +256,11 @@ class LibraryDeskClient:
|
||||
"vector_limit": vector_limit,
|
||||
"graph_limit": graph_limit,
|
||||
"web_limit": web_limit,
|
||||
"document_limit": document_limit,
|
||||
"volatile_limit": volatile_limit,
|
||||
"enable_documents": document_limit > 0,
|
||||
"enable_volatile": volatile_limit > 0,
|
||||
"enable_web": web_limit > 0,
|
||||
"enable_reranking": enable_reranking,
|
||||
"final_result_count": final_result_count,
|
||||
},
|
||||
|
||||
@@ -17,20 +17,26 @@ logger = get_logger(__name__)
|
||||
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 documents)
|
||||
- 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.
|
||||
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
|
||||
@@ -38,12 +44,16 @@ async def hybrid_search(
|
||||
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:
|
||||
@@ -70,6 +80,8 @@ async def hybrid_search(
|
||||
"vector": "📄",
|
||||
"graph": "🔗",
|
||||
"web": "🌐",
|
||||
"document": "📑",
|
||||
"volatile": "⚡",
|
||||
}.get(result.source, "•")
|
||||
|
||||
output_parts.append(
|
||||
|
||||
+110
-29
@@ -5,11 +5,13 @@ The Steward analyzes incoming requests, identifies relevant household
|
||||
capabilities, and provides focused recommendations to Tatlock (the Butler).
|
||||
This creates a two-tier architecture that prevents cognitive overload.
|
||||
|
||||
Uses plain text output (not JSON) for reliability with Ollama models.
|
||||
Uses plain text output (not JSON) for reliability. Supports both Claude
|
||||
(preferred) and Ollama (fallback) backends via direct API calls.
|
||||
"""
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
from src.anthropic.model_selector import is_claude_available, get_model_info
|
||||
from src.core.config import config
|
||||
from src.core.household_registry import get_household_registry
|
||||
from src.core.logging_config import get_logger
|
||||
@@ -56,14 +58,22 @@ USER QUERY: {query}
|
||||
GUIDELINES:
|
||||
- Be conservative - only recommend truly necessary capabilities
|
||||
- Simple greetings/chat → no capabilities needed (conversational response only)
|
||||
- Questions about prior conversation ("what did I say", "my name", "what we discussed") → no capabilities (Tatlock has full history)
|
||||
- Questions about prior conversation ("what did I say", "what we discussed") → no capabilities (Tatlock has full history)
|
||||
- Math/calculations → tatlock_core
|
||||
- Time/date queries → tatlock_core
|
||||
- PERSONAL MEMORY queries → biographer to recall (ALWAYS use for questions about the user themselves):
|
||||
- "where do I live", "what's my location", "my address" → biographer to recall location
|
||||
- "what's my name", "who am I" → biographer to recall name
|
||||
- "what car do I drive", "my vehicle" → biographer to recall car
|
||||
- "what do you know about me", "what have I told you" → biographer to recall or list_memories
|
||||
- "remember that I...", "store that..." → biographer to store_insight
|
||||
- "forget my...", "delete..." → biographer to forget_memory
|
||||
- "my timezone", "my preferences" → biographer to recall preferences
|
||||
- Web searches, weather, news, current information → librarian with search_web
|
||||
- Read a URL or article → librarian with read_url
|
||||
- Wiki creation ("create a page about X", "add X to wiki") → librarian with smart_create
|
||||
- Wiki updates ("update the page", "add to dossier") → librarian with update
|
||||
- Research queries ("find info", "what do we know about", "search for") → librarian with hybrid_search
|
||||
- Research queries about TOPICS (not about the user) → librarian with hybrid_search
|
||||
- In-depth research, knowledge synthesis, document lookup → librarian with hybrid_search
|
||||
- If conversation history is relevant, note which previous turns matter
|
||||
- Assess complexity: simple (1 tool), moderate (2-3 tools), complex (multiple steps)
|
||||
@@ -75,6 +85,10 @@ COMPLEXITY: [simple/moderate/complex]
|
||||
CONTEXT: [any relevant conversation context, or "none"]
|
||||
|
||||
EXAMPLES:
|
||||
- "DELEGATE: biographer to recall the user's location" (for "where do I live?")
|
||||
- "DELEGATE: biographer to recall the user's car" (for "what car do I drive?")
|
||||
- "DELEGATE: biographer to list_memories about the user" (for "what do you know about me?")
|
||||
- "DELEGATE: biographer to store_insight about user's pet" (for "remember that I have a dog named Max")
|
||||
- "DELEGATE: librarian to search_web for tomorrow's weather forecast"
|
||||
- "DELEGATE: librarian to create a wiki page about CI/CD pipelines"
|
||||
- "DELEGATE: librarian to hybrid_search for information about Docker networking"
|
||||
@@ -93,22 +107,74 @@ class StewardAgent:
|
||||
Analyzes requests with full conversation context and recommends
|
||||
which household capabilities the Butler should use.
|
||||
|
||||
Uses plain text output for reliability with Ollama models.
|
||||
Uses plain text output for reliability. Supports both Claude
|
||||
(preferred) and Ollama (fallback) backends via direct API calls.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Steward with Ollama model (same as Tatlock for VRAM efficiency)."""
|
||||
"""Initialize Steward with backend selection based on availability."""
|
||||
# Ollama config (fallback)
|
||||
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||
self.model_name = config.OLLAMA_DEFAULT_MODEL
|
||||
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
|
||||
|
||||
# Claude config (preferred)
|
||||
self.claude_model = config.ANTHROPIC_MODEL
|
||||
self._anthropic_client = None
|
||||
|
||||
# Determine which backend to use
|
||||
self._use_claude = config.PREFER_CLOUD_BACKEND and is_claude_available()
|
||||
|
||||
self.timeout = 30.0 # 30 second timeout for analysis
|
||||
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"steward_agent_created",
|
||||
ollama_host=self.ollama_host,
|
||||
model=self.model_name,
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def _get_anthropic_client(self):
|
||||
"""Get or create Anthropic client (lazy initialization)."""
|
||||
if self._anthropic_client is None:
|
||||
from anthropic import AsyncAnthropic
|
||||
self._anthropic_client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||
return self._anthropic_client
|
||||
|
||||
async def _call_claude(self, system_prompt: str, user_message: str) -> str:
|
||||
"""Call Claude API directly for plain text generation."""
|
||||
client = self._get_anthropic_client()
|
||||
|
||||
response = await client.messages.create(
|
||||
model=self.claude_model,
|
||||
max_tokens=1024,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
temperature=0.3, # Lower = more consistent
|
||||
)
|
||||
|
||||
return response.content[0].text.strip()
|
||||
|
||||
async def _call_ollama(self, prompt: str) -> str:
|
||||
"""Call Ollama API directly for plain text generation."""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.ollama_host}/api/generate",
|
||||
json={
|
||||
"model": self.ollama_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["response"].strip()
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
query: str,
|
||||
@@ -117,6 +183,8 @@ class StewardAgent:
|
||||
"""
|
||||
Analyze query and return plain text recommendation.
|
||||
|
||||
Uses Claude if available, falls back to Ollama.
|
||||
|
||||
Args:
|
||||
query: User's query to analyze
|
||||
conversation_history: Previous conversation turns
|
||||
@@ -132,35 +200,48 @@ class StewardAgent:
|
||||
history = conversation_history or []
|
||||
prompt = build_steward_prompt(query, history)
|
||||
|
||||
logger.debug("steward_calling_ollama", query_preview=query[:100])
|
||||
backend = "claude" if self._use_claude else "ollama"
|
||||
logger.debug(
|
||||
"steward_calling_llm",
|
||||
backend=backend,
|
||||
query_preview=query[:100],
|
||||
)
|
||||
|
||||
# Call Ollama API directly (more reliable than PydanticAI for plain text)
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.ollama_host}/api/generate",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
analysis_text = result["response"].strip()
|
||||
try:
|
||||
if self._use_claude:
|
||||
# For Claude, split into system + user message
|
||||
# The prompt contains both, but Claude prefers explicit system
|
||||
analysis_text = await self._call_claude(
|
||||
system_prompt="You are the Steward of the household, advising the Butler (Tatlock) on which capabilities to use. Be concise and specific.",
|
||||
user_message=prompt,
|
||||
)
|
||||
else:
|
||||
analysis_text = await self._call_ollama(prompt)
|
||||
|
||||
logger.debug(
|
||||
"steward_analysis_received",
|
||||
text_preview=analysis_text[:150]
|
||||
backend=backend,
|
||||
text_preview=analysis_text[:150],
|
||||
)
|
||||
|
||||
return analysis_text
|
||||
|
||||
except Exception as e:
|
||||
# If Claude fails, try Ollama as fallback
|
||||
if self._use_claude:
|
||||
logger.warning(
|
||||
"steward_claude_fallback",
|
||||
error=str(e),
|
||||
)
|
||||
analysis_text = await self._call_ollama(prompt)
|
||||
logger.debug(
|
||||
"steward_analysis_received",
|
||||
backend="ollama_fallback",
|
||||
text_preview=analysis_text[:150],
|
||||
)
|
||||
return analysis_text
|
||||
raise
|
||||
|
||||
|
||||
# Global Steward instance
|
||||
_steward_agent = None
|
||||
|
||||
@@ -236,7 +236,9 @@ async def _prefetch_memory_context(user_request: str) -> dict[str, Any]:
|
||||
# Location-related queries
|
||||
if any(word in request_lower for word in [
|
||||
"weather", "temperature", "forecast", "nearby", "local",
|
||||
"directions", "distance", "map", "here"
|
||||
"directions", "distance", "map", "here",
|
||||
# Direct location questions
|
||||
"live", "where", "home", "reside", "location", "address",
|
||||
]):
|
||||
profile_keys.append("location")
|
||||
|
||||
|
||||
+40
-71
@@ -47,7 +47,16 @@ def generate_id() -> str:
|
||||
# System prompt defining Tatlock's personality
|
||||
TATLOCK_SYSTEM_PROMPT = """You are Tatlock, a helpful personal assistant with the demeanor of a British butler.
|
||||
|
||||
Address users as "sir" and maintain a formal yet personable tone. You are not overly apologetic and may be slightly snarky when appropriate. If an opportunity for a pun presents itself, you cannot resist.
|
||||
## Personality
|
||||
|
||||
Address users as "sir". Be confident, direct, and efficient - you are an unflappable English butler who gets things done. Dry wit and puns are encouraged.
|
||||
|
||||
**CRITICAL - Do NOT:**
|
||||
- Apologize unless you genuinely made an error
|
||||
- Say "Apologies for any confusion" or "Allow me to rectify" when nothing went wrong
|
||||
- Preface successful results with caveats or apologies
|
||||
|
||||
When presenting findings: lead with the answer, be concise, skip the preamble.
|
||||
|
||||
You coordinate with various household staff (expert agents) to provide comprehensive assistance across:
|
||||
- Research and knowledge work
|
||||
@@ -129,10 +138,7 @@ class TatlockAgent(AgentInterface):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Tatlock configuration (lazy agent creation)."""
|
||||
# Store Ollama configuration
|
||||
self.ollama_host = str(config.OLLAMA_HOST)
|
||||
self.model_name = config.OLLAMA_DEFAULT_MODEL
|
||||
"""Initialize Tatlock (lazy agent creation)."""
|
||||
self._agent = None # Lazy initialization
|
||||
|
||||
def _ensure_agent(self):
|
||||
@@ -140,30 +146,21 @@ class TatlockAgent(AgentInterface):
|
||||
if self._agent is not None:
|
||||
return
|
||||
|
||||
from src.anthropic.model_selector import get_model, get_model_info
|
||||
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"tatlock_agent_initializing",
|
||||
ollama_host=self.ollama_host,
|
||||
model=self.model_name,
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
)
|
||||
|
||||
# Import required classes for Ollama configuration
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
# Get best available model (Claude if available, else Ollama)
|
||||
model = get_model()
|
||||
|
||||
# PydanticAI expects Ollama base URL to end with /v1
|
||||
# Remove trailing slash from ollama_host if present
|
||||
clean_host = self.ollama_host.rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
# Create Ollama model with provider
|
||||
ollama_model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=get_ollama_provider()
|
||||
)
|
||||
|
||||
# Create PydanticAI agent with Ollama model
|
||||
# Create PydanticAI agent
|
||||
self._agent = Agent(
|
||||
ollama_model,
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
@@ -452,8 +449,7 @@ class TatlockAgent(AgentInterface):
|
||||
... tool_tracker=tracker,
|
||||
... )
|
||||
"""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
"tatlock_run_with_scoped_tools",
|
||||
@@ -464,18 +460,12 @@ class TatlockAgent(AgentInterface):
|
||||
|
||||
# Create a fresh agent instance with scoped tools only
|
||||
# This ensures Tatlock can ONLY use tools recommended by the Steward
|
||||
clean_host = self.ollama_host.rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
ollama_model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=get_ollama_provider()
|
||||
)
|
||||
model = get_model()
|
||||
|
||||
# Create agent with scoped tools
|
||||
# Tools from household registry are already PydanticAI Tool objects
|
||||
scoped_agent = Agent(
|
||||
ollama_model,
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
tools=scoped_tools, # Pass tools directly to Agent constructor
|
||||
)
|
||||
@@ -504,13 +494,13 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
# Force tool_choice: required to make LLM actually call tools
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
# Force tool_choice to make LLM actually call tools
|
||||
from src.anthropic.model_selector import get_tool_choice_settings
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker,
|
||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
||||
model_settings=get_tool_choice_settings(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -546,8 +536,7 @@ class TatlockAgent(AgentInterface):
|
||||
Yields:
|
||||
Text chunks from the streaming response
|
||||
"""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
"tatlock_run_with_scoped_tools_stream",
|
||||
@@ -557,17 +546,11 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
|
||||
# Create a fresh agent instance with scoped tools only
|
||||
clean_host = self.ollama_host.rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
ollama_model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=get_ollama_provider()
|
||||
)
|
||||
model = get_model()
|
||||
|
||||
# Create agent with scoped tools
|
||||
scoped_agent = Agent(
|
||||
ollama_model,
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
tools=scoped_tools,
|
||||
)
|
||||
@@ -641,9 +624,6 @@ class TatlockAgent(AgentInterface):
|
||||
- tool_outputs: Dict mapping tool names to their outputs
|
||||
- raw_output: The agent's raw text output
|
||||
"""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
from pydantic_ai.messages import (
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
@@ -652,6 +632,7 @@ class TatlockAgent(AgentInterface):
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
)
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
"tatlock_orchestrate_tool_calls",
|
||||
@@ -671,17 +652,11 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
|
||||
# Create a fresh agent instance with scoped tools only
|
||||
clean_host = self.ollama_host.rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
ollama_model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=get_ollama_provider()
|
||||
)
|
||||
model = get_model()
|
||||
|
||||
# Create agent with scoped tools
|
||||
scoped_agent = Agent(
|
||||
ollama_model,
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
tools=scoped_tools,
|
||||
)
|
||||
@@ -708,11 +683,12 @@ class TatlockAgent(AgentInterface):
|
||||
)
|
||||
|
||||
# Run with scoped tools and tracker
|
||||
from src.anthropic.model_selector import get_tool_choice_settings
|
||||
result = await scoped_agent.run(
|
||||
enriched_message,
|
||||
message_history=pydantic_history if pydantic_history else None,
|
||||
deps=tool_tracker,
|
||||
model_settings=ModelSettings(extra_body={"tool_choice": "required"})
|
||||
model_settings=get_tool_choice_settings(),
|
||||
)
|
||||
|
||||
# Extract tool calls and results from the agent's messages
|
||||
@@ -790,9 +766,8 @@ class TatlockAgent(AgentInterface):
|
||||
Returns:
|
||||
str: Butler-toned response synthesized from all results
|
||||
"""
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
|
||||
from src.anthropic.model_selector import get_model
|
||||
|
||||
logger.info(
|
||||
"tatlock_synthesize_from_results",
|
||||
@@ -831,25 +806,19 @@ class TatlockAgent(AgentInterface):
|
||||
synthesis_parts.append("")
|
||||
|
||||
synthesis_parts.append(
|
||||
"Based on this information, provide a response to the user. "
|
||||
"Maintain your butler personality - address them as 'sir', "
|
||||
"use formal but personable language, and be helpful."
|
||||
"Synthesize a response for the user. Be direct and confident. "
|
||||
"Lead with the answer - no apologies, no caveats, no 'mix-ups'. "
|
||||
"Address them as 'sir', be concise, add dry wit if appropriate."
|
||||
)
|
||||
|
||||
synthesis_prompt = "\n".join(synthesis_parts)
|
||||
|
||||
# Create synthesis agent (no tools needed)
|
||||
clean_host = self.ollama_host.rstrip('/')
|
||||
base_url = f"{clean_host}/v1"
|
||||
|
||||
ollama_model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=get_ollama_provider()
|
||||
)
|
||||
model = get_model()
|
||||
|
||||
# Synthesis agent uses butler prompt but no tools
|
||||
synthesis_agent = Agent(
|
||||
ollama_model,
|
||||
model,
|
||||
system_prompt=TATLOCK_SYSTEM_PROMPT,
|
||||
# No tools for synthesis phase
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Anthropic/Claude integration module.
|
||||
|
||||
Provides model selection with automatic fallback between Claude and Ollama.
|
||||
"""
|
||||
|
||||
from src.anthropic.model_selector import (
|
||||
check_claude_health,
|
||||
get_model,
|
||||
get_tool_choice_settings,
|
||||
is_claude_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"check_claude_health",
|
||||
"get_model",
|
||||
"get_tool_choice_settings",
|
||||
"is_claude_available",
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Model selector for Claude/Ollama backend switching.
|
||||
|
||||
Provides automatic model selection with Claude as preferred backend
|
||||
and Ollama as offline fallback.
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
from pydantic_ai.models.anthropic import AnthropicModel
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||
|
||||
from src.core.config import config
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Cached health check result (set once at startup)
|
||||
_claude_available: bool | None = None
|
||||
|
||||
|
||||
async def check_claude_health() -> bool:
|
||||
"""
|
||||
Check if Claude API is reachable and working.
|
||||
|
||||
This should be called once at application startup.
|
||||
The result is cached in `_claude_available`.
|
||||
|
||||
Returns:
|
||||
True if Claude API is accessible, False otherwise.
|
||||
"""
|
||||
global _claude_available
|
||||
|
||||
# No API key configured - Claude not available
|
||||
if not config.ANTHROPIC_API_KEY:
|
||||
logger.info(
|
||||
"claude_health_check_skipped",
|
||||
reason="no_api_key",
|
||||
)
|
||||
_claude_available = False
|
||||
return False
|
||||
|
||||
try:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||
|
||||
# Minimal API call to verify connectivity
|
||||
# Using a tiny max_tokens to minimize cost
|
||||
await client.messages.create(
|
||||
model=config.ANTHROPIC_MODEL,
|
||||
max_tokens=1,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
_claude_available = True
|
||||
logger.info(
|
||||
"claude_health_check_passed",
|
||||
model=config.ANTHROPIC_MODEL,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_claude_available = False
|
||||
logger.warning(
|
||||
"claude_health_check_failed",
|
||||
error=str(e),
|
||||
model=config.ANTHROPIC_MODEL,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def is_claude_available() -> bool:
|
||||
"""
|
||||
Check if Claude is available (from cached health check result).
|
||||
|
||||
Returns:
|
||||
True if Claude API was reachable at startup, False otherwise.
|
||||
|
||||
Note:
|
||||
Returns False if health check hasn't been run yet.
|
||||
Call `check_claude_health()` at startup first.
|
||||
"""
|
||||
return _claude_available is True
|
||||
|
||||
|
||||
def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIChatModel]:
|
||||
"""
|
||||
Get the best available model.
|
||||
|
||||
Returns Claude if available and preferred, otherwise Ollama.
|
||||
|
||||
Args:
|
||||
prefer_cloud: Override config.PREFER_CLOUD_BACKEND for this call.
|
||||
If None, uses the config value.
|
||||
|
||||
Returns:
|
||||
PydanticAI model instance (AnthropicModel or OpenAIChatModel).
|
||||
|
||||
Example:
|
||||
>>> model = get_model()
|
||||
>>> agent = Agent(model, system_prompt="...")
|
||||
"""
|
||||
# Determine preference
|
||||
use_cloud = prefer_cloud if prefer_cloud is not None else config.PREFER_CLOUD_BACKEND
|
||||
|
||||
# Use Claude if available and preferred
|
||||
if use_cloud and is_claude_available():
|
||||
logger.debug(
|
||||
"model_selected",
|
||||
backend="claude",
|
||||
model=config.ANTHROPIC_MODEL,
|
||||
)
|
||||
return AnthropicModel(
|
||||
model_name=config.ANTHROPIC_MODEL,
|
||||
provider=AnthropicProvider(api_key=config.ANTHROPIC_API_KEY),
|
||||
)
|
||||
|
||||
# Fall back to Ollama
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
|
||||
logger.debug(
|
||||
"model_selected",
|
||||
backend="ollama",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
reason="fallback" if use_cloud else "preferred_local",
|
||||
)
|
||||
return OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
provider=get_ollama_provider(),
|
||||
)
|
||||
|
||||
|
||||
def get_tool_choice_settings() -> 'ModelSettings':
|
||||
"""
|
||||
Get model_settings for forcing tool calls on the first request.
|
||||
|
||||
For Claude: PydanticAI handles tool_choice natively, so no extra_body needed.
|
||||
For Ollama: Pass tool_choice="required" via extra_body to force tool calling.
|
||||
"""
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
if is_claude_available() and config.PREFER_CLOUD_BACKEND:
|
||||
# PydanticAI's Anthropic model handles tool_choice internally
|
||||
return ModelSettings()
|
||||
else:
|
||||
# Ollama needs explicit tool_choice via extra_body
|
||||
return ModelSettings(extra_body={"tool_choice": "required"})
|
||||
|
||||
|
||||
def get_model_info() -> dict:
|
||||
"""
|
||||
Get information about the current model configuration.
|
||||
|
||||
Useful for health checks and debugging.
|
||||
|
||||
Returns:
|
||||
Dict with backend, model name, and availability info.
|
||||
"""
|
||||
use_cloud = config.PREFER_CLOUD_BACKEND and is_claude_available()
|
||||
|
||||
return {
|
||||
"backend": "claude" if use_cloud else "ollama",
|
||||
"model": config.ANTHROPIC_MODEL if use_cloud else config.OLLAMA_DEFAULT_MODEL,
|
||||
"claude_available": is_claude_available(),
|
||||
"claude_configured": bool(config.ANTHROPIC_API_KEY),
|
||||
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
|
||||
}
|
||||
+22
-18
@@ -7,7 +7,7 @@ import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from src.chat import service
|
||||
from src.chat.schemas import (
|
||||
@@ -22,47 +22,51 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
async def _stream_response(
|
||||
request: ChatCompletionRequest,
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Generate SSE stream for chat completion.
|
||||
|
||||
EventSourceResponse adds "data: " prefix automatically.
|
||||
We just yield the dict/string content.
|
||||
Yields raw SSE-formatted strings matching OpenAI's format exactly:
|
||||
data: {json}\n\n
|
||||
"""
|
||||
try:
|
||||
async for chunk in service.create_chat_completion_stream(request):
|
||||
# Yield dict - EventSourceResponse will format as SSE
|
||||
yield {"data": chunk.model_dump_json()}
|
||||
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
|
||||
|
||||
# Send [DONE] message
|
||||
yield {"data": "[DONE]"}
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in streaming response: {e}")
|
||||
error_data = {"error": {"message": str(e), "type": "internal_error"}}
|
||||
yield {"data": json.dumps(error_data)}
|
||||
error_data = json.dumps({"error": {"message": str(e), "type": "internal_error"}})
|
||||
yield f"data: {error_data}\n\n"
|
||||
|
||||
|
||||
@router.post("/completions", response_model=ChatCompletionResponse)
|
||||
async def create_chat_completion(
|
||||
request: ChatCompletionRequest,
|
||||
) -> ChatCompletionResponse | EventSourceResponse:
|
||||
) -> ChatCompletionResponse | StreamingResponse:
|
||||
"""
|
||||
Create chat completion (OpenAI-compatible).
|
||||
|
||||
|
||||
Supports both regular and streaming responses.
|
||||
Currently returns mock lorem ipsum responses.
|
||||
|
||||
|
||||
Args:
|
||||
request: Chat completion request
|
||||
|
||||
|
||||
Returns:
|
||||
Chat completion response or SSE stream
|
||||
"""
|
||||
logger.info(f"Chat completion request for model: {request.model}")
|
||||
|
||||
|
||||
if request.stream:
|
||||
logger.info("Streaming response requested")
|
||||
return EventSourceResponse(_stream_response(request))
|
||||
|
||||
return StreamingResponse(
|
||||
_stream_response(request),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
return await service.create_chat_completion(request)
|
||||
|
||||
+15
-1
@@ -64,7 +64,21 @@ class Config(BaseSettings):
|
||||
API_PORT: int = Field(default=8000, description="API port")
|
||||
API_PREFIX: str = Field(default="/v1", description="API route prefix")
|
||||
|
||||
# Ollama Configuration
|
||||
# Anthropic Configuration (Claude - preferred backend)
|
||||
ANTHROPIC_API_KEY: str | None = Field(
|
||||
default=None,
|
||||
description="Anthropic API key for Claude access"
|
||||
)
|
||||
ANTHROPIC_MODEL: str = Field(
|
||||
default="claude-sonnet-4-20250514",
|
||||
description="Claude model to use"
|
||||
)
|
||||
PREFER_CLOUD_BACKEND: bool = Field(
|
||||
default=True,
|
||||
description="Prefer Claude over Ollama when available"
|
||||
)
|
||||
|
||||
# Ollama Configuration (local fallback)
|
||||
OLLAMA_HOST: HttpUrl = Field(
|
||||
default="http://localhost:11434",
|
||||
description="Ollama server URL"
|
||||
|
||||
+15
-4
@@ -9,6 +9,7 @@ from src.agents.biographer import register_biographer
|
||||
from src.agents.housekeeper import register_housekeeper
|
||||
from src.agents.librarian import register_librarian
|
||||
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
||||
from src.anthropic.model_selector import check_claude_health, get_model_info
|
||||
from src.core.household_registry import get_household_registry
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -81,19 +82,29 @@ def register_household_members():
|
||||
)
|
||||
|
||||
|
||||
def initialize_application():
|
||||
async def initialize_application():
|
||||
"""
|
||||
Initialize the application.
|
||||
|
||||
Performs all startup tasks:
|
||||
1. Register household members
|
||||
2. (Future) Initialize connections
|
||||
3. (Future) Load configuration
|
||||
1. Check Claude API health (for backend selection)
|
||||
2. Register household members
|
||||
3. (Future) Initialize connections
|
||||
|
||||
This should be called once during application startup.
|
||||
"""
|
||||
logger.info("application_initialization_starting")
|
||||
|
||||
# Check Claude API health for backend selection
|
||||
await check_claude_health()
|
||||
model_info = get_model_info()
|
||||
logger.info(
|
||||
"model_backend_configured",
|
||||
backend=model_info["backend"],
|
||||
model=model_info["model"],
|
||||
claude_available=model_info["claude_available"],
|
||||
)
|
||||
|
||||
# Register household members
|
||||
register_household_members()
|
||||
|
||||
|
||||
+4
-2
@@ -44,14 +44,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
app_name=config.APP_NAME,
|
||||
version=config.APP_VERSION,
|
||||
environment=config.ENVIRONMENT.value,
|
||||
prefer_cloud=config.PREFER_CLOUD_BACKEND,
|
||||
anthropic_model=config.ANTHROPIC_MODEL,
|
||||
ollama_host=str(config.OLLAMA_HOST),
|
||||
ollama_model=config.OLLAMA_DEFAULT_MODEL,
|
||||
redis_url=config.redis_memory_url,
|
||||
log_format=config.log_format,
|
||||
)
|
||||
|
||||
# Initialize application (register household members, etc.)
|
||||
initialize_application()
|
||||
# Initialize application (check Claude health, register household members, etc.)
|
||||
await initialize_application()
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -651,15 +651,13 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
|
||||
# Build response output items
|
||||
output_items = []
|
||||
|
||||
# Add Steward reasoning as a reasoning output item
|
||||
output_items.append(ReasoningOutputItem(
|
||||
id=f"reasoning_{generate_id()}",
|
||||
summary=[
|
||||
"🎩 Steward's Analysis:",
|
||||
enriched.steward_reasoning,
|
||||
],
|
||||
status="completed"
|
||||
))
|
||||
# Add Steward reasoning as reasoning output
|
||||
if enriched.steward_reasoning:
|
||||
output_items.append(ReasoningOutputItem(
|
||||
id=f"rs_{generate_id()}",
|
||||
summary=[enriched.steward_reasoning],
|
||||
status="completed"
|
||||
))
|
||||
|
||||
# Add Tatlock's message
|
||||
output_items.append(MessageOutputItem(
|
||||
|
||||
@@ -166,26 +166,6 @@ class StreamingCoordinator:
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Stream Steward's analysis as reasoning summary
|
||||
steward_lines = enriched.steward_reasoning.split('\n')
|
||||
for line in steward_lines:
|
||||
if line.strip():
|
||||
yield ReasoningSummaryDelta(delta=line + "\n")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
yield ReasoningSummaryDone()
|
||||
|
||||
# Add Steward reasoning to output items
|
||||
reasoning_item = ReasoningOutputItem(
|
||||
id=f"reasoning_{generate_id()}",
|
||||
summary=[
|
||||
"🎩 Steward's Analysis:",
|
||||
enriched.steward_reasoning,
|
||||
],
|
||||
status="completed"
|
||||
)
|
||||
output_items.append(reasoning_item)
|
||||
|
||||
# Initialize tool tracker
|
||||
tracker = ToolCallTracker(
|
||||
recommended_capabilities=enriched.recommendation.recommended_capabilities,
|
||||
|
||||
@@ -9,13 +9,13 @@ import pytest
|
||||
|
||||
from src.agents.steward.schemas import ConversationContext, StewardRecommendation
|
||||
from src.agents.steward.service import analyze_request, format_steward_note, _build_enriched_query
|
||||
from src.core.startup import initialize_application
|
||||
from src.core.startup import register_household_members
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_household_registry():
|
||||
"""Initialize household registry before running tests."""
|
||||
initialize_application()
|
||||
register_household_members()
|
||||
|
||||
|
||||
class TestAnalyzeRequest:
|
||||
@@ -29,17 +29,14 @@ class TestAnalyzeRequest:
|
||||
mock_agent.analyze = AsyncMock(return_value="Simple greeting requires no tools. This is a simple request.")
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
result = await analyze_request(
|
||||
"Hello!",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
result = await analyze_request(
|
||||
"Hello!",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
assert result.recommended_capabilities == []
|
||||
assert result.estimated_complexity == "simple"
|
||||
assert mock_agent.analyze.called
|
||||
assert result.recommended_capabilities == []
|
||||
assert result.estimated_complexity == "simple"
|
||||
assert mock_agent.analyze.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_math_request(self):
|
||||
@@ -50,16 +47,13 @@ class TestAnalyzeRequest:
|
||||
)
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
result = await analyze_request(
|
||||
"What's sqrt(144)?",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
result = await analyze_request(
|
||||
"What's sqrt(144)?",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
assert "tatlock_core" in result.recommended_capabilities
|
||||
assert result.estimated_complexity == "simple"
|
||||
assert "tatlock_core" in result.recommended_capabilities
|
||||
assert result.estimated_complexity == "simple"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_conversation_history(self):
|
||||
@@ -75,21 +69,18 @@ class TestAnalyzeRequest:
|
||||
]
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
result = await analyze_request(
|
||||
"And what's that times 5?",
|
||||
conversation_history=conversation_history,
|
||||
)
|
||||
|
||||
result = await analyze_request(
|
||||
"And what's that times 5?",
|
||||
conversation_history=conversation_history,
|
||||
)
|
||||
assert result.conversation_context.has_previous_context is True
|
||||
assert 0 in result.conversation_context.relevant_turns
|
||||
|
||||
assert result.conversation_context.has_previous_context is True
|
||||
assert 0 in result.conversation_context.relevant_turns
|
||||
|
||||
# Verify conversation history was passed
|
||||
call_kwargs = mock_agent.analyze.call_args.kwargs
|
||||
assert "conversation_history" in call_kwargs
|
||||
assert len(call_kwargs["conversation_history"]) == 2
|
||||
# Verify conversation history was passed
|
||||
call_kwargs = mock_agent.analyze.call_args.kwargs
|
||||
assert "conversation_history" in call_kwargs
|
||||
assert len(call_kwargs["conversation_history"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_missing_capabilities(self):
|
||||
@@ -100,16 +91,13 @@ class TestAnalyzeRequest:
|
||||
)
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
result = await analyze_request(
|
||||
"Generate an image of a sunset",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
result = await analyze_request(
|
||||
"Generate an image of a sunset",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
assert result.missing_capabilities is not None
|
||||
assert "not available" in result.missing_capabilities
|
||||
assert result.missing_capabilities is not None
|
||||
assert "not available" in result.missing_capabilities
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_conversation_id(self):
|
||||
@@ -120,18 +108,15 @@ class TestAnalyzeRequest:
|
||||
)
|
||||
|
||||
with patch("src.agents.steward.service.get_steward_agent", return_value=mock_agent):
|
||||
with patch("src.agents.steward.service.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
result = await analyze_request(
|
||||
"Test request",
|
||||
conversation_history=[],
|
||||
conversation_id="test_conv_123",
|
||||
)
|
||||
|
||||
result = await analyze_request(
|
||||
"Test request",
|
||||
conversation_history=[],
|
||||
conversation_id="test_conv_123",
|
||||
)
|
||||
|
||||
# Verify analysis completed successfully
|
||||
assert result.recommended_capabilities == ["tatlock_core"]
|
||||
assert result.estimated_complexity == "simple"
|
||||
# Verify analysis completed successfully
|
||||
assert result.recommended_capabilities == ["tatlock_core"]
|
||||
assert result.estimated_complexity == "simple"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_handles_errors(self):
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data_1,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
@@ -56,7 +56,7 @@ async def test_tatlock_conversation_history_memory(async_client: AsyncClient):
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data_2,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
@@ -95,7 +95,7 @@ async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_1,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
@@ -117,7 +117,7 @@ async def test_tatlock_multi_turn_context(async_client: AsyncClient):
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_2,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
@@ -150,7 +150,7 @@ async def test_tatlock_tool_call_logging_search(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=60.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -192,7 +192,7 @@ async def test_tatlock_tool_call_logging_calculator(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -243,7 +243,7 @@ async def test_tatlock_tool_call_logging_datetime(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -293,7 +293,7 @@ async def test_tatlock_no_tool_calls_no_logging(async_client: AsyncClient):
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -336,7 +336,7 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
response_1 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_1,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_1.status_code == 200
|
||||
@@ -362,7 +362,7 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
response_2 = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_2,
|
||||
timeout=30.0
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response_2.status_code == 200
|
||||
@@ -378,3 +378,55 @@ async def test_tatlock_conversation_history_with_tools(async_client: AsyncClient
|
||||
)
|
||||
if not has_calculation:
|
||||
pytest.xfail(f"LLM did not remember calculation (non-deterministic): {second_response[:200]}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_tatlock_ollama_fallback(async_client: AsyncClient):
|
||||
"""
|
||||
Test that Tatlock falls back to Ollama when Claude is unavailable.
|
||||
|
||||
Patches _claude_available to False to force the Ollama path,
|
||||
then verifies the system still produces a valid response.
|
||||
"""
|
||||
import src.anthropic.model_selector as model_selector
|
||||
|
||||
# Save original value
|
||||
original = model_selector._claude_available
|
||||
|
||||
try:
|
||||
# Force Ollama fallback
|
||||
model_selector._claude_available = False
|
||||
|
||||
# Verify we're actually using Ollama
|
||||
info = model_selector.get_model_info()
|
||||
assert info["backend"] == "ollama", f"Expected ollama backend, got {info['backend']}"
|
||||
|
||||
request_data = {
|
||||
"model": "Tatlock",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Say hello to me."}
|
||||
],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/v1/chat/completions",
|
||||
json=request_data,
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure is valid
|
||||
assert "choices" in data
|
||||
assert len(data["choices"]) == 1
|
||||
full_response = data["choices"][0]["message"]["content"]
|
||||
assert len(full_response) > 0, "Ollama should produce a non-empty response"
|
||||
|
||||
print(f"\nOllama fallback response: {full_response[:200]}")
|
||||
|
||||
finally:
|
||||
# Restore original value
|
||||
model_selector._claude_available = original
|
||||
|
||||
+16
-3
@@ -2,6 +2,8 @@
|
||||
Shared test fixtures for all tests.
|
||||
Following FastAPI testing best practices.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
@@ -9,11 +11,22 @@ from httpx import AsyncClient, ASGITransport
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _initialize_app():
|
||||
"""
|
||||
Run application lifespan (Claude health check, household registration, etc.)
|
||||
once per test session. ASGITransport doesn't trigger lifespan events,
|
||||
so we call it explicitly.
|
||||
"""
|
||||
from src.core.startup import initialize_application
|
||||
asyncio.run(initialize_application())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
"""
|
||||
Synchronous test client for FastAPI.
|
||||
|
||||
|
||||
Use for simple tests that don't require async.
|
||||
"""
|
||||
return TestClient(app)
|
||||
@@ -23,7 +36,7 @@ def client() -> TestClient:
|
||||
async def async_client() -> AsyncClient:
|
||||
"""
|
||||
Async test client for FastAPI.
|
||||
|
||||
|
||||
Use for testing async endpoints and streaming.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
@@ -37,7 +50,7 @@ async def async_client() -> AsyncClient:
|
||||
def mock_chat_request() -> dict:
|
||||
"""Standard chat completion request fixture."""
|
||||
return {
|
||||
"model": "Tatlock",
|
||||
"model": "lorem-tester",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, world!"}
|
||||
],
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
"""
|
||||
Tests for benchmark storage.
|
||||
|
||||
Tests performance tracking, Redis storage, and analytics features.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.benchmarks import (
|
||||
BenchmarkStore,
|
||||
PerformanceBenchmark,
|
||||
get_benchmark_store,
|
||||
)
|
||||
|
||||
|
||||
class TestPerformanceBenchmark:
|
||||
"""Test PerformanceBenchmark model."""
|
||||
|
||||
def test_benchmark_creation(self):
|
||||
"""Test creating a performance benchmark."""
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="steward_analysis",
|
||||
duration_seconds=1.23,
|
||||
success=True,
|
||||
recommendation_count=3,
|
||||
)
|
||||
|
||||
assert benchmark.operation == "steward_analysis"
|
||||
assert benchmark.duration_seconds == 1.23
|
||||
assert benchmark.success is True
|
||||
assert benchmark.recommendation_count == 3
|
||||
assert isinstance(benchmark.timestamp, datetime)
|
||||
|
||||
def test_benchmark_with_tool_fields(self):
|
||||
"""Test benchmark with tool-specific fields."""
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="tool_call",
|
||||
duration_seconds=0.5,
|
||||
success=True,
|
||||
tool_name="calculate",
|
||||
was_recommended=True,
|
||||
was_actually_used=True,
|
||||
)
|
||||
|
||||
assert benchmark.tool_name == "calculate"
|
||||
assert benchmark.was_recommended is True
|
||||
assert benchmark.was_actually_used is True
|
||||
|
||||
def test_benchmark_to_redis_dict(self):
|
||||
"""Test conversion to Redis dict."""
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="test_op",
|
||||
duration_seconds=1.0,
|
||||
success=True,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
redis_dict = benchmark.to_redis_dict()
|
||||
assert redis_dict["operation"] == "test_op"
|
||||
assert redis_dict["duration_seconds"] == 1.0
|
||||
assert redis_dict["success"] == "True" # Booleans stored as strings in Redis
|
||||
assert isinstance(redis_dict["timestamp"], str)
|
||||
assert isinstance(redis_dict["metadata"], str)
|
||||
|
||||
def test_benchmark_from_redis_dict(self):
|
||||
"""Test reconstruction from Redis dict."""
|
||||
now = datetime.now(timezone.utc)
|
||||
redis_dict = {
|
||||
"timestamp": now.isoformat(),
|
||||
"operation": "test_op",
|
||||
"duration_seconds": 1.5,
|
||||
"success": "True", # Booleans stored as strings in Redis
|
||||
"metadata": json.dumps({"test": "data"}),
|
||||
"recommendation_count": None,
|
||||
"confidence": None,
|
||||
"tool_name": None,
|
||||
"was_recommended": None,
|
||||
"was_actually_used": None,
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
benchmark = PerformanceBenchmark.from_redis_dict(redis_dict)
|
||||
assert benchmark.operation == "test_op"
|
||||
assert benchmark.duration_seconds == 1.5
|
||||
assert benchmark.success is True # Converted back to bool
|
||||
assert benchmark.metadata == {"test": "data"}
|
||||
|
||||
|
||||
class TestBenchmarkStore:
|
||||
"""Test BenchmarkStore functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
"""Create mock Redis client."""
|
||||
mock = AsyncMock()
|
||||
mock.hset = AsyncMock()
|
||||
mock.expire = AsyncMock()
|
||||
mock.zadd = AsyncMock()
|
||||
mock.zrevrangebyscore = AsyncMock(return_value=[])
|
||||
mock.hgetall = AsyncMock(return_value={})
|
||||
mock.aclose = AsyncMock()
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def store(self, mock_redis):
|
||||
"""Create benchmark store with mock Redis."""
|
||||
return BenchmarkStore(redis_client=mock_redis)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_benchmark(self, store, mock_redis):
|
||||
"""Test recording a benchmark."""
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="test_op",
|
||||
duration_seconds=1.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
await store.record(benchmark)
|
||||
|
||||
# Verify Redis calls
|
||||
mock_redis.hset.assert_called_once()
|
||||
mock_redis.expire.assert_called()
|
||||
mock_redis.zadd.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_benchmark_disabled(self, mock_redis):
|
||||
"""Test recording when benchmarks are disabled."""
|
||||
with patch("src.core.benchmarks.config.ENABLE_BENCHMARKS", False):
|
||||
store = BenchmarkStore(redis_client=mock_redis)
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="test_op",
|
||||
duration_seconds=1.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
await store.record(benchmark)
|
||||
|
||||
# Should not call Redis
|
||||
mock_redis.hset.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_benchmark_handles_errors(self, store, mock_redis):
|
||||
"""Test recording handles Redis errors gracefully."""
|
||||
mock_redis.hset.side_effect = Exception("Redis error")
|
||||
|
||||
benchmark = PerformanceBenchmark(
|
||||
operation="test_op",
|
||||
duration_seconds=1.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Should not raise exception
|
||||
await store.record(benchmark)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_benchmarks(self, store, mock_redis):
|
||||
"""Test querying benchmarks."""
|
||||
# Setup mock data
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_key = f"benchmark:test_op:{int(now.timestamp() * 1000)}"
|
||||
mock_redis.zrevrangebyscore.return_value = [mock_key]
|
||||
|
||||
# Mock hgetall to return proper data (booleans as strings, like Redis)
|
||||
mock_redis.hgetall.return_value = {
|
||||
"timestamp": now.isoformat(),
|
||||
"operation": "test_op",
|
||||
"duration_seconds": 1.5, # Numeric, not string
|
||||
"success": "True", # Booleans stored as strings in Redis
|
||||
"metadata": "{}",
|
||||
"recommendation_count": None,
|
||||
"confidence": None,
|
||||
"tool_name": None,
|
||||
"was_recommended": None,
|
||||
"was_actually_used": None,
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
results = await store.query("test_op", limit=10)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].operation == "test_op"
|
||||
mock_redis.zrevrangebyscore.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_with_time_range(self, store, mock_redis):
|
||||
"""Test querying with time range."""
|
||||
now = datetime.now(timezone.utc)
|
||||
start_time = now - timedelta(hours=1)
|
||||
end_time = now
|
||||
|
||||
await store.query("test_op", start_time=start_time, end_time=end_time)
|
||||
|
||||
# Verify time range was converted to timestamps
|
||||
call_args = mock_redis.zrevrangebyscore.call_args
|
||||
assert call_args is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_disabled_benchmarks(self, mock_redis):
|
||||
"""Test querying when benchmarks are disabled."""
|
||||
with patch("src.core.benchmarks.config.ENABLE_BENCHMARKS", False):
|
||||
store = BenchmarkStore(redis_client=mock_redis)
|
||||
results = await store.query("test_op")
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_handles_errors(self, store, mock_redis):
|
||||
"""Test query handles errors gracefully."""
|
||||
mock_redis.zrevrangebyscore.side_effect = Exception("Redis error")
|
||||
|
||||
results = await store.query("test_op")
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics(self, store, mock_redis):
|
||||
"""Test getting statistics."""
|
||||
# Setup mock data with multiple benchmarks
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_keys = [
|
||||
f"benchmark:test_op:{int((now - timedelta(seconds=i)).timestamp() * 1000)}"
|
||||
for i in range(3)
|
||||
]
|
||||
mock_redis.zrevrangebyscore.return_value = mock_keys
|
||||
|
||||
# Return different durations and success values
|
||||
benchmarks_data = [
|
||||
{"duration_seconds": "1.0", "success": "True"},
|
||||
{"duration_seconds": "2.0", "success": "True"},
|
||||
{"duration_seconds": "3.0", "success": "False"},
|
||||
]
|
||||
|
||||
async def mock_hgetall(key):
|
||||
idx = mock_keys.index(key)
|
||||
data = benchmarks_data[idx]
|
||||
return {
|
||||
"timestamp": now.isoformat(),
|
||||
"operation": "test_op",
|
||||
"duration_seconds": float(data["duration_seconds"]),
|
||||
"success": data["success"], # Pass string through, from_redis_dict converts
|
||||
"metadata": "{}",
|
||||
"recommendation_count": None,
|
||||
"confidence": None,
|
||||
"tool_name": None,
|
||||
"was_recommended": None,
|
||||
"was_actually_used": None,
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
mock_redis.hgetall.side_effect = mock_hgetall
|
||||
|
||||
stats = await store.get_statistics("test_op")
|
||||
|
||||
assert stats["count"] == 3
|
||||
assert stats["avg_duration"] == 2.0 # (1 + 2 + 3) / 3
|
||||
assert stats["min_duration"] == 1.0
|
||||
assert stats["max_duration"] == 3.0
|
||||
assert stats["success_rate"] == pytest.approx(66.67, rel=0.01)
|
||||
assert stats["total_successes"] == 2
|
||||
assert stats["total_failures"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics_empty(self, store, mock_redis):
|
||||
"""Test statistics with no data."""
|
||||
mock_redis.zrevrangebyscore.return_value = []
|
||||
|
||||
stats = await store.get_statistics("test_op")
|
||||
|
||||
assert stats["count"] == 0
|
||||
assert stats["avg_duration"] == 0.0
|
||||
assert stats["success_rate"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_accuracy(self, store, mock_redis):
|
||||
"""Test tool accuracy calculation."""
|
||||
# Setup mock data
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_keys = [
|
||||
f"benchmark:tool_call:{int((now - timedelta(seconds=i)).timestamp() * 1000)}"
|
||||
for i in range(4)
|
||||
]
|
||||
mock_redis.zrevrangebyscore.return_value = mock_keys
|
||||
|
||||
# Different combinations of recommended/used
|
||||
tool_data = [
|
||||
{"was_recommended": "True", "was_actually_used": "True"}, # Good
|
||||
{"was_recommended": "True", "was_actually_used": "True"}, # Good
|
||||
{"was_recommended": "False", "was_actually_used": "True"}, # Missed
|
||||
{"was_recommended": "True", "was_actually_used": "False"}, # Not used
|
||||
]
|
||||
|
||||
async def mock_hgetall(key):
|
||||
idx = mock_keys.index(key)
|
||||
data = tool_data[idx]
|
||||
return {
|
||||
"timestamp": now.isoformat(),
|
||||
"operation": "tool_call",
|
||||
"duration_seconds": 1.0,
|
||||
"success": "True", # Booleans stored as strings in Redis
|
||||
"metadata": "{}",
|
||||
"recommendation_count": None,
|
||||
"confidence": None,
|
||||
"tool_name": "test_tool",
|
||||
"conversation_id": None,
|
||||
"was_recommended": data["was_recommended"], # Already strings
|
||||
"was_actually_used": data["was_actually_used"], # Already strings
|
||||
}
|
||||
|
||||
mock_redis.hgetall.side_effect = mock_hgetall
|
||||
|
||||
accuracy = await store.get_tool_accuracy()
|
||||
|
||||
assert accuracy["total_calls"] == 4
|
||||
assert accuracy["total_used"] == 3
|
||||
assert accuracy["recommended_and_used"] == 2
|
||||
assert accuracy["not_recommended_but_used"] == 1
|
||||
assert accuracy["precision"] == pytest.approx(66.67, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_accuracy_empty(self, store, mock_redis):
|
||||
"""Test tool accuracy with no data."""
|
||||
mock_redis.zrevrangebyscore.return_value = []
|
||||
|
||||
accuracy = await store.get_tool_accuracy()
|
||||
|
||||
assert accuracy["total_calls"] == 0
|
||||
assert accuracy["precision"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close(self, store, mock_redis):
|
||||
"""Test closing the store."""
|
||||
await store.close()
|
||||
mock_redis.aclose.assert_called_once()
|
||||
|
||||
# Client should be None after close
|
||||
assert store._client is None
|
||||
|
||||
|
||||
class TestGlobalBenchmarkStore:
|
||||
"""Test global benchmark store instance."""
|
||||
|
||||
def test_get_benchmark_store(self):
|
||||
"""Test getting global store instance."""
|
||||
store = get_benchmark_store()
|
||||
assert isinstance(store, BenchmarkStore)
|
||||
|
||||
def test_get_benchmark_store_singleton(self):
|
||||
"""Test store is singleton."""
|
||||
store1 = get_benchmark_store()
|
||||
store2 = get_benchmark_store()
|
||||
assert store1 is store2
|
||||
@@ -3,8 +3,6 @@ Tests for tool call tracking.
|
||||
|
||||
Tests capability extraction and recommendation matching.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.tool_tracking import ToolCallTracker
|
||||
@@ -35,15 +33,11 @@ class TestToolCallTracker:
|
||||
recommended_capabilities=["librarian", "biographer"]
|
||||
)
|
||||
|
||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
await tracker.track_call("delegate_to_librarian", 1.0)
|
||||
|
||||
await tracker.track_call("delegate_to_librarian", 1.0)
|
||||
|
||||
# Should NOT log warning since librarian was recommended
|
||||
call_args = mock_store.return_value.record.call_args
|
||||
benchmark = call_args[0][0]
|
||||
assert benchmark.was_recommended is True
|
||||
# Should record the call
|
||||
assert "delegate_to_librarian" in tracker.actual_calls
|
||||
assert tracker.actual_calls["delegate_to_librarian"] == [1.0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_call_detects_not_recommended(self):
|
||||
@@ -52,14 +46,12 @@ class TestToolCallTracker:
|
||||
recommended_capabilities=["librarian"]
|
||||
)
|
||||
|
||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
await tracker.track_call("delegate_to_housekeeper", 1.0)
|
||||
|
||||
await tracker.track_call("delegate_to_housekeeper", 1.0)
|
||||
|
||||
call_args = mock_store.return_value.record.call_args
|
||||
benchmark = call_args[0][0]
|
||||
assert benchmark.was_recommended is False
|
||||
# Should record the call even though not recommended
|
||||
assert "delegate_to_housekeeper" in tracker.actual_calls
|
||||
summary = tracker.get_summary()
|
||||
assert summary["accuracy"]["not_recommended_but_used"] == 1
|
||||
|
||||
def test_get_summary_with_delegation_tools(self):
|
||||
"""Test summary correctly maps delegation tools to capabilities."""
|
||||
@@ -87,15 +79,9 @@ class TestToolCallTracker:
|
||||
"delegate_to_librarian": [1.0],
|
||||
}
|
||||
|
||||
with patch("src.core.tool_tracking.get_benchmark_store") as mock_store:
|
||||
mock_store.return_value.record = AsyncMock()
|
||||
await tracker.finalize()
|
||||
|
||||
await tracker.finalize()
|
||||
|
||||
# Should record benchmark for unused biographer
|
||||
assert mock_store.return_value.record.called
|
||||
call_args = mock_store.return_value.record.call_args
|
||||
benchmark = call_args[0][0]
|
||||
assert benchmark.tool_name == "biographer"
|
||||
assert benchmark.was_recommended is True
|
||||
assert benchmark.was_actually_used is False
|
||||
# Summary should show biographer as recommended but unused
|
||||
summary = tracker.get_summary()
|
||||
assert summary["accuracy"]["recommended_and_used"] == 1 # librarian
|
||||
assert summary["accuracy"]["recommended_but_unused"] == 1 # biographer
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
|
||||
|
||||
#!/bin/bash
|
||||
# Tatlock Server Startup Script
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Starting Tatlock server...${NC}"
|
||||
|
||||
# Check if port 8777 is already in use
|
||||
if lsof -Pi :8777 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
echo -e "${RED}Error: Port 8777 is already in use${NC}"
|
||||
echo "Run: lsof -i :8777 to see what's using it"
|
||||
echo "Or run: kill \$(lsof -t -i:8777) to stop it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Activate virtual environment if not already activated
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
if [ -d ".venv" ]; then
|
||||
echo -e "${YELLOW}Activating virtual environment...${NC}"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo -e "${RED}Error: Virtual environment not found${NC}"
|
||||
echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
LOGS_DIR="logs"
|
||||
mkdir -p "$LOGS_DIR"
|
||||
|
||||
# Clear/create log file
|
||||
LOG_FILE="$LOGS_DIR/server.log"
|
||||
> "$LOG_FILE"
|
||||
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
||||
|
||||
# Start the server
|
||||
echo -e "${GREEN}Starting uvicorn server on http://tower-of-joy:8777${NC}"
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
||||
echo ""
|
||||
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8777 2>&1 | tee "$LOG_FILE"
|
||||
Reference in New Issue
Block a user