style: apply ruff's automatic fixes and formatter
Mechanical only, and separated from the judgment calls that follow so the reviewable changes are not buried in a 98-file whitespace diff. 227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller modernisations. Then `ruff format` over src and tests: 98 files reformatted, 35 already conforming. No file among the unused-import findings defines __all__ or is an __init__.py, so nothing here removes a re-export. `make test`: 658 passed, unchanged from HEAD. Two things observed while verifying, neither addressed here: `pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered, and the config is strict about markers. This fails identically at HEAD, so it predates this change; `make test` passes because it ignores tests/e2e, tests/integration and tests/contracts. test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run with these changes and passed on the next, passes in isolation with them, and fails in isolation at HEAD. It is order- or timing-dependent, not a regression from this commit — established by running the full suite both ways rather than by reasoning about which change could have caused it. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+34
-86
@@ -2,6 +2,7 @@
|
||||
Global application configuration.
|
||||
Following best practice of splitting config across domains.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -42,6 +43,7 @@ def _get_version_from_pyproject() -> str:
|
||||
|
||||
class Environment(str, Enum):
|
||||
"""Application environment."""
|
||||
|
||||
DEVELOPMENT = "development"
|
||||
PRODUCTION = "production"
|
||||
TESTING = "testing"
|
||||
@@ -54,6 +56,7 @@ class Config(BaseSettings):
|
||||
Loads from environment variables and .env file.
|
||||
Domain-specific configs should be in their respective modules.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@@ -74,143 +77,90 @@ class Config(BaseSettings):
|
||||
|
||||
# Anthropic Configuration (Claude - cloud fallback)
|
||||
ANTHROPIC_API_KEY: str | None = Field(
|
||||
default=None,
|
||||
description="Anthropic API key for the Claude fallback backend"
|
||||
default=None, description="Anthropic API key for the Claude fallback backend"
|
||||
)
|
||||
ANTHROPIC_MODEL: str = Field(
|
||||
default="claude-sonnet-5",
|
||||
description="Claude model for the fallback backend"
|
||||
default="claude-sonnet-5", description="Claude model for the fallback backend"
|
||||
)
|
||||
PREFER_CLOUD_BACKEND: bool = Field(
|
||||
default=False,
|
||||
description="Prefer Claude over Ollama (default: local-first)"
|
||||
default=False, description="Prefer Claude over Ollama (default: local-first)"
|
||||
)
|
||||
|
||||
# Ollama Configuration (local - primary backend)
|
||||
OLLAMA_HOST: HttpUrl = Field(
|
||||
default="http://localhost:11434",
|
||||
description="Ollama server URL"
|
||||
)
|
||||
OLLAMA_DEFAULT_MODEL: str = Field(
|
||||
default="gemma4:e2b",
|
||||
description="Default Ollama model"
|
||||
)
|
||||
OLLAMA_TIMEOUT: int = Field(
|
||||
default=120,
|
||||
description="Ollama request timeout in seconds"
|
||||
)
|
||||
OLLAMA_HOST: HttpUrl = Field(default="http://localhost:11434", description="Ollama server URL")
|
||||
OLLAMA_DEFAULT_MODEL: str = Field(default="gemma4:e2b", description="Default Ollama model")
|
||||
OLLAMA_TIMEOUT: int = Field(default=120, description="Ollama request timeout in seconds")
|
||||
STEWARD_TIMEOUT: int = Field(
|
||||
default=60,
|
||||
description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
|
||||
default=60, description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
|
||||
)
|
||||
STREAM_TIMEOUT: int = Field(
|
||||
default=20,
|
||||
description="Timeout for each streaming turn in seconds"
|
||||
default=20, description="Timeout for each streaming turn in seconds"
|
||||
)
|
||||
|
||||
# SearXNG Configuration
|
||||
SEARXNG_HOST: HttpUrl = Field(
|
||||
default="http://searxng:8080",
|
||||
description="SearXNG server URL (container name; internal port 8080)"
|
||||
)
|
||||
SEARXNG_TIMEOUT: int = Field(
|
||||
default=30,
|
||||
description="SearXNG request timeout in seconds"
|
||||
description="SearXNG server URL (container name; internal port 8080)",
|
||||
)
|
||||
SEARXNG_TIMEOUT: int = Field(default=30, description="SearXNG request timeout in seconds")
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST: str = Field(
|
||||
default="localhost",
|
||||
description="Redis server host"
|
||||
)
|
||||
REDIS_PORT: int = Field(
|
||||
default=6379,
|
||||
description="Redis server port"
|
||||
)
|
||||
REDIS_TIMEOUT: int = Field(
|
||||
default=5,
|
||||
description="Redis connection timeout in seconds"
|
||||
)
|
||||
REDIS_HOST: str = Field(default="localhost", description="Redis server host")
|
||||
REDIS_PORT: int = Field(default=6379, description="Redis server port")
|
||||
REDIS_TIMEOUT: int = Field(default=5, description="Redis connection timeout in seconds")
|
||||
|
||||
# Library-Desk Configuration (The Librarian backend)
|
||||
LIBRARIAN_TIMEOUT: int = Field(
|
||||
default=180,
|
||||
description="Total time budget for a librarian delegation in seconds"
|
||||
default=180, description="Total time budget for a librarian delegation in seconds"
|
||||
)
|
||||
LIBRARY_DESK_HOST: HttpUrl = Field(
|
||||
default="http://library-desk:8089",
|
||||
description="Library-Desk API URL (container name; internal port 8089)"
|
||||
description="Library-Desk API URL (container name; internal port 8089)",
|
||||
)
|
||||
LIBRARY_DESK_API_KEY: str = Field(
|
||||
default="",
|
||||
description="API key for Library-Desk authentication"
|
||||
default="", description="API key for Library-Desk authentication"
|
||||
)
|
||||
LIBRARY_DESK_TIMEOUT: int = Field(
|
||||
default=60,
|
||||
description="Library-Desk request timeout in seconds"
|
||||
default=60, description="Library-Desk request timeout in seconds"
|
||||
)
|
||||
|
||||
# Core-API Configuration (The Housekeeper backend)
|
||||
CORE_API_HOST: HttpUrl = Field(
|
||||
default="http://core-api:8083",
|
||||
description="Core-API URL for Home Assistant integration (container name; internal port 8083)"
|
||||
)
|
||||
CORE_API_KEY: str = Field(
|
||||
default="",
|
||||
description="API key for Core-API authentication"
|
||||
)
|
||||
CORE_API_TIMEOUT: int = Field(
|
||||
default=30,
|
||||
description="Core-API request timeout in seconds"
|
||||
description="Core-API URL for Home Assistant integration (container name; internal port 8083)",
|
||||
)
|
||||
CORE_API_KEY: str = Field(default="", description="API key for Core-API authentication")
|
||||
CORE_API_TIMEOUT: int = Field(default=30, description="Core-API request timeout in seconds")
|
||||
|
||||
# Qdrant Configuration (Memory vector storage)
|
||||
QDRANT_HOST: str = Field(
|
||||
default="localhost",
|
||||
description="Qdrant server host"
|
||||
)
|
||||
QDRANT_PORT: int = Field(
|
||||
default=6333,
|
||||
description="Qdrant server port"
|
||||
)
|
||||
QDRANT_HOST: str = Field(default="localhost", description="Qdrant server host")
|
||||
QDRANT_PORT: int = Field(default=6333, description="Qdrant server port")
|
||||
QDRANT_EMBEDDING_DIM: int = Field(
|
||||
default=768,
|
||||
description="Embedding dimension (768 for nomic-embed-text)"
|
||||
default=768, description="Embedding dimension (768 for nomic-embed-text)"
|
||||
)
|
||||
|
||||
# Ollama Embedding Configuration
|
||||
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||
default="nomic-embed-text",
|
||||
description="Ollama model for embeddings"
|
||||
default="nomic-embed-text", description="Ollama model for embeddings"
|
||||
)
|
||||
|
||||
# Redis Memory Database
|
||||
REDIS_MEMORY_DB: int = Field(
|
||||
default=1,
|
||||
description="Redis database number for memory cache"
|
||||
)
|
||||
REDIS_MEMORY_TTL_HOURS: int = Field(
|
||||
default=24,
|
||||
description="TTL for session context in hours"
|
||||
)
|
||||
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")
|
||||
REDIS_MEMORY_TTL_HOURS: int = Field(default=24, description="TTL for session context in hours")
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str | None = Field(
|
||||
default=None,
|
||||
description="Logging level (auto-set based on environment if not specified)"
|
||||
default=None, description="Logging level (auto-set based on environment if not specified)"
|
||||
)
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER: str | None = Field(
|
||||
default=None,
|
||||
description="Default user for single-user setup (auto-set based on environment if not specified)"
|
||||
description="Default user for single-user setup (auto-set based on environment if not specified)",
|
||||
)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = Field(
|
||||
default=["*"],
|
||||
description="Allowed CORS origins"
|
||||
)
|
||||
CORS_ORIGINS: list[str] = Field(default=["*"], description="Allowed CORS origins")
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: list[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: list[str] = ["*"]
|
||||
@@ -235,8 +185,7 @@ class Config(BaseSettings):
|
||||
if (
|
||||
self.ENVIRONMENT != Environment.PRODUCTION
|
||||
and self.DEFAULT_USER is not None
|
||||
and sanitize_user_id(self.DEFAULT_USER)
|
||||
== sanitize_user_id(PRODUCTION_TENANT)
|
||||
and sanitize_user_id(self.DEFAULT_USER) == sanitize_user_id(PRODUCTION_TENANT)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Refusing to start: ENVIRONMENT={self.ENVIRONMENT.value} is "
|
||||
@@ -299,8 +248,7 @@ class Config(BaseSettings):
|
||||
return self.DEFAULT_USER or PRODUCTION_TENANT
|
||||
|
||||
if self.DEFAULT_USER is not None and (
|
||||
self.DEFAULT_USER == TEST_TENANT
|
||||
or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
|
||||
self.DEFAULT_USER == TEST_TENANT or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
|
||||
):
|
||||
return self.DEFAULT_USER
|
||||
return TEST_TENANT
|
||||
|
||||
+5
-6
@@ -16,6 +16,7 @@ Usage:
|
||||
from src.core.context import get_user
|
||||
user = get_user() # Returns current request's user
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ def get_default_user() -> str:
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from src.core.config import config
|
||||
|
||||
return config.effective_default_user
|
||||
|
||||
|
||||
@@ -36,9 +38,7 @@ def get_default_user() -> str:
|
||||
# and resolve the real default in get_user()
|
||||
_USER_NOT_SET = "__user_not_set__"
|
||||
current_user: ContextVar[str] = ContextVar("current_user", default=_USER_NOT_SET)
|
||||
current_conversation: ContextVar[str | None] = ContextVar(
|
||||
"current_conversation", default=None
|
||||
)
|
||||
current_conversation: ContextVar[str | None] = ContextVar("current_conversation", default=None)
|
||||
|
||||
|
||||
def apply_tenant_guard(user: str) -> str:
|
||||
@@ -60,9 +60,8 @@ def apply_tenant_guard(user: str) -> str:
|
||||
from src.core.config import PRODUCTION_TENANT, TEST_TENANT, Environment, config
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
if (
|
||||
config.ENVIRONMENT != Environment.PRODUCTION
|
||||
and sanitize_user_id(user) == sanitize_user_id(PRODUCTION_TENANT)
|
||||
if config.ENVIRONMENT != Environment.PRODUCTION and sanitize_user_id(user) == sanitize_user_id(
|
||||
PRODUCTION_TENANT
|
||||
):
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ Provides async embedding operations via Ollama API:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Global exception definitions.
|
||||
Domain-specific exceptions should be in their respective modules.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""Base exception for all application errors."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "An error occurred",
|
||||
@@ -22,26 +23,26 @@ class AppException(Exception):
|
||||
|
||||
class OllamaConnectionError(AppException):
|
||||
"""Raised when cannot connect to Ollama service."""
|
||||
|
||||
|
||||
def __init__(self, message: str = "Cannot connect to Ollama service"):
|
||||
super().__init__(message=message, status_code=503)
|
||||
|
||||
|
||||
class OllamaTimeoutError(AppException):
|
||||
"""Raised when Ollama request times out."""
|
||||
|
||||
|
||||
def __init__(self, message: str = "Ollama request timed out"):
|
||||
super().__init__(message=message, status_code=504)
|
||||
|
||||
|
||||
class ModelNotFoundError(AppException):
|
||||
"""Raised when requested model is not available."""
|
||||
|
||||
|
||||
def __init__(self, model_name: str):
|
||||
super().__init__(
|
||||
message=f"Model '{model_name}' not found",
|
||||
status_code=404,
|
||||
details={"model": model_name}
|
||||
details={"model": model_name},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ Provides centralized registry of household members (agents) with their
|
||||
capabilities and tools. Supports two-tier abstraction: executive summaries
|
||||
for coordination and full toolsets for execution.
|
||||
"""
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from .logging_config import get_logger
|
||||
|
||||
@@ -22,6 +22,7 @@ class HouseholdCapability(BaseModel):
|
||||
This is what the Steward and Butler see for coordination.
|
||||
High-level description without implementation details.
|
||||
"""
|
||||
|
||||
name: str # Unique identifier: "tatlock_core", "librarian", "developer"
|
||||
role: str # Display name: "Butler's Core Tools", "The Librarian"
|
||||
category: str # "core", "research", "technical", "automation"
|
||||
@@ -38,11 +39,12 @@ class HouseholdMember(BaseModel):
|
||||
Contains both the executive summary (for coordination) and
|
||||
implementation details (tools/agent).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
capability: HouseholdCapability
|
||||
tools: list[Any] # PydanticAI tool definitions (any type since Tool is a dataclass)
|
||||
agent: Optional[Any] = None # For expert agents (Phase 4)
|
||||
agent: Any | None = None # For expert agents (Phase 4)
|
||||
|
||||
|
||||
class HouseholdRegistry:
|
||||
@@ -65,7 +67,7 @@ class HouseholdRegistry:
|
||||
name: str,
|
||||
capability: HouseholdCapability,
|
||||
tools: list[Any],
|
||||
agent: Optional[Any] = None,
|
||||
agent: Any | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a household member.
|
||||
@@ -95,9 +97,7 @@ class HouseholdRegistry:
|
||||
... )
|
||||
"""
|
||||
if name != capability.name:
|
||||
raise ValueError(
|
||||
f"Name mismatch: '{name}' != '{capability.name}'"
|
||||
)
|
||||
raise ValueError(f"Name mismatch: '{name}' != '{capability.name}'")
|
||||
|
||||
self._members[name] = HouseholdMember(
|
||||
capability=capability,
|
||||
@@ -132,7 +132,7 @@ class HouseholdRegistry:
|
||||
role=member.capability.role,
|
||||
)
|
||||
|
||||
def get_member(self, name: str) -> Optional[HouseholdMember]:
|
||||
def get_member(self, name: str) -> HouseholdMember | None:
|
||||
"""
|
||||
Get full household member specification.
|
||||
|
||||
|
||||
+33
-13
@@ -4,12 +4,14 @@ Structured logging configuration using structlog.
|
||||
Deeply integrates with FastAPI/uvicorn's built-in logging to provide
|
||||
seamless structured logs across the entire application stack.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from structlog.types import EventDict, Processor
|
||||
@@ -19,7 +21,7 @@ from .config import config
|
||||
|
||||
def add_timestamp(logger: Any, method_name: str, event_dict: EventDict) -> EventDict:
|
||||
"""Add ISO 8601 timestamp to log entries."""
|
||||
event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
event_dict["timestamp"] = datetime.now(UTC).isoformat()
|
||||
return event_dict
|
||||
|
||||
|
||||
@@ -41,11 +43,28 @@ def extract_from_record(logger: Any, method_name: str, event_dict: EventDict) ->
|
||||
# Extract custom fields from record
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in {
|
||||
"name", "msg", "args", "created", "filename", "funcName",
|
||||
"levelname", "levelno", "lineno", "module", "msecs",
|
||||
"message", "pathname", "process", "processName", "relativeCreated",
|
||||
"thread", "threadName", "exc_info", "exc_text", "stack_info",
|
||||
"taskName"
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"created",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"taskName",
|
||||
}:
|
||||
event_dict[key] = value
|
||||
|
||||
@@ -164,7 +183,7 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
async def log_operation(
|
||||
operation: str,
|
||||
initial_context: dict[str, Any] | None = None,
|
||||
logger_name: str = "tatlock.operations"
|
||||
logger_name: str = "tatlock.operations",
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""
|
||||
Context manager for automatic operation timing and logging.
|
||||
@@ -187,21 +206,21 @@ async def log_operation(
|
||||
context = initial_context or {}
|
||||
context["operation"] = operation
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
start_time = datetime.now(UTC)
|
||||
logger.info("operation_started", **context)
|
||||
|
||||
try:
|
||||
yield context
|
||||
|
||||
# Success case
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
duration = (datetime.now(UTC) - start_time).total_seconds()
|
||||
context["duration_seconds"] = duration
|
||||
context["success"] = True
|
||||
logger.info("operation_completed", **context)
|
||||
|
||||
except Exception as e:
|
||||
# Error case
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
duration = (datetime.now(UTC) - start_time).total_seconds()
|
||||
context["duration_seconds"] = duration
|
||||
context["success"] = False
|
||||
context["error"] = str(e)
|
||||
@@ -228,7 +247,8 @@ def get_uvicorn_log_config() -> dict[str, Any]:
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processors": [
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.JSONRenderer() if config.log_format == "json"
|
||||
structlog.processors.JSONRenderer()
|
||||
if config.log_format == "json"
|
||||
else structlog.dev.ConsoleRenderer(colors=True),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ Provides short-term memory storage with TTL:
|
||||
|
||||
Uses Redis DB 1.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +16,7 @@ import redis.asyncio as redis
|
||||
|
||||
from .config import config
|
||||
from .logging_config import get_logger
|
||||
from .multi_tenancy import get_session_key, get_entities_key
|
||||
from .multi_tenancy import get_entities_key, get_session_key
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
+15
-13
@@ -21,14 +21,14 @@ Usage:
|
||||
# Get session context
|
||||
ctx = await memory_service.get_session_context(conversation_id)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .config import config
|
||||
from .context import get_user, get_conversation_id
|
||||
from .context import get_conversation_id, get_user
|
||||
from .embeddings import get_embedding_client
|
||||
from .logging_config import get_logger
|
||||
from .memory_cache import get_memory_cache
|
||||
@@ -40,22 +40,24 @@ logger = get_logger(__name__)
|
||||
|
||||
class MemoryType(str, Enum):
|
||||
"""Types of memories stored in Qdrant."""
|
||||
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||
PREFERENCE = "preference" # Units, language, theme
|
||||
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||
|
||||
USER_PROFILE = "user_profile" # Name, location, timezone
|
||||
PREFERENCE = "preference" # Units, language, theme
|
||||
LEARNED_FACT = "learned_fact" # "My car is a Tesla"
|
||||
|
||||
|
||||
class MemoryRecord(BaseModel):
|
||||
"""A memory record stored in Qdrant."""
|
||||
|
||||
id: str
|
||||
type: MemoryType
|
||||
key: str # e.g., "location", "timezone", "car"
|
||||
value: str # The actual content
|
||||
key: str # e.g., "location", "timezone", "car"
|
||||
value: str # The actual content
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
importance: float = 0.5 # 0.0 - 1.0
|
||||
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
importance: float = 0.5 # 0.0 - 1.0
|
||||
source: str = "explicit" # "explicit" | "inferred" | "conversation"
|
||||
created_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
|
||||
|
||||
class MemoryService:
|
||||
@@ -528,7 +530,7 @@ class MemoryService:
|
||||
"keywords": keywords,
|
||||
"importance": importance,
|
||||
"source": source,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
result = await self.qdrant.upsert_memory(
|
||||
|
||||
+6
-5
@@ -2,6 +2,7 @@
|
||||
Custom Pydantic base models for consistent serialization.
|
||||
Following best practice of having a global base model.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -17,12 +18,13 @@ def datetime_to_iso_str(dt: datetime) -> str:
|
||||
class CustomBaseModel(BaseModel):
|
||||
"""
|
||||
Custom base model with consistent configuration.
|
||||
|
||||
|
||||
All domain models should inherit from this for:
|
||||
- Consistent JSON serialization
|
||||
- Timezone-aware datetime handling
|
||||
- Alias population support
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_encoders={datetime: datetime_to_iso_str},
|
||||
populate_by_name=True,
|
||||
@@ -30,14 +32,13 @@ class CustomBaseModel(BaseModel):
|
||||
validate_assignment=True,
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
def serializable_dict(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Return dict with only JSON-serializable fields.
|
||||
|
||||
|
||||
Useful for logging and debugging.
|
||||
"""
|
||||
return jsonable_encoder(
|
||||
self.model_dump(**kwargs),
|
||||
custom_encoder={datetime: datetime_to_iso_str}
|
||||
self.model_dump(**kwargs), custom_encoder={datetime: datetime_to_iso_str}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ Provides utilities for user namespace management across:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
@@ -39,13 +40,13 @@ def sanitize_user_id(user_id: str) -> str:
|
||||
sanitized = sanitized.replace(".", "_")
|
||||
|
||||
# Replace any non-alphanumeric characters with underscores
|
||||
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
||||
sanitized = re.sub(r"[^a-z0-9_]", "_", sanitized)
|
||||
|
||||
# Remove consecutive underscores
|
||||
sanitized = re.sub(r'_+', '_', sanitized)
|
||||
sanitized = re.sub(r"_+", "_", sanitized)
|
||||
|
||||
# Remove leading/trailing underscores
|
||||
sanitized = sanitized.strip('_')
|
||||
sanitized = sanitized.strip("_")
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -141,7 +142,7 @@ def validate_user_id(user_id: str) -> bool:
|
||||
return False
|
||||
|
||||
# Must contain at least one alphanumeric character
|
||||
if not re.search(r'[a-zA-Z0-9]', user_id):
|
||||
if not re.search(r"[a-zA-Z0-9]", user_id):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
+14
-12
@@ -3,15 +3,16 @@ Request preprocessing pipeline.
|
||||
|
||||
Analyzes requests via the Steward and creates scoped toolsets for Tatlock.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from src.agents.steward import analyze_request, format_steward_note
|
||||
from src.agents.steward.schemas import StewardRecommendation
|
||||
from src.core.household_registry import get_household_registry
|
||||
from src.core.logging_config import get_logger
|
||||
from src.core.tracing import trace_span, SpanType
|
||||
from src.core.tracing import SpanType, trace_span
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -45,6 +46,7 @@ class EnrichedRequest:
|
||||
recommendation: Full Steward recommendation
|
||||
steward_reasoning: Plain text reasoning for streaming to user
|
||||
"""
|
||||
|
||||
original_request: str
|
||||
steward_note: str
|
||||
scoped_tools: list[Any] # PydanticAI tool definitions
|
||||
@@ -55,7 +57,7 @@ class EnrichedRequest:
|
||||
async def preprocess_request(
|
||||
user_request: str,
|
||||
conversation_history: list[dict],
|
||||
conversation_id: Optional[str] = None,
|
||||
conversation_id: str | None = None,
|
||||
) -> EnrichedRequest:
|
||||
"""
|
||||
Analyze request via Steward and prepare scoped context for Tatlock.
|
||||
@@ -111,12 +113,14 @@ async def preprocess_request(
|
||||
|
||||
# Update span with results
|
||||
if span:
|
||||
span.metadata.update({
|
||||
"recommended_capabilities": recommendation.recommended_capabilities,
|
||||
"complexity": recommendation.estimated_complexity,
|
||||
"has_memory_context": bool(recommendation.memory_context),
|
||||
"has_conversation_context": recommendation.conversation_context.has_previous_context,
|
||||
})
|
||||
span.metadata.update(
|
||||
{
|
||||
"recommended_capabilities": recommendation.recommended_capabilities,
|
||||
"complexity": recommendation.estimated_complexity,
|
||||
"has_memory_context": bool(recommendation.memory_context),
|
||||
"has_conversation_context": recommendation.conversation_context.has_previous_context,
|
||||
}
|
||||
)
|
||||
span.details["reasoning"] = recommendation.reasoning
|
||||
if recommendation.enriched_query:
|
||||
span.details["enriched_query"] = recommendation.enriched_query
|
||||
@@ -128,9 +132,7 @@ async def preprocess_request(
|
||||
# Uses agent-as-tool pattern: expert agents get delegation wrappers,
|
||||
# core tools are returned directly
|
||||
registry = get_household_registry()
|
||||
scoped_tools = registry.get_delegation_tools(
|
||||
recommendation.recommended_capabilities
|
||||
)
|
||||
scoped_tools = registry.get_delegation_tools(recommendation.recommended_capabilities)
|
||||
|
||||
logger.info(
|
||||
"preprocessing_complete",
|
||||
|
||||
+2
-1
@@ -8,8 +8,9 @@ Provides async operations for storing and retrieving memory embeddings:
|
||||
|
||||
Adapted from library-desk patterns.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4, uuid5, NAMESPACE_DNS
|
||||
from uuid import NAMESPACE_DNS, uuid4, uuid5
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qdrant_models
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core router for health and root endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -16,7 +17,7 @@ router = APIRouter(tags=["core"])
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
|
||||
Returns:
|
||||
Health status
|
||||
"""
|
||||
@@ -27,7 +28,7 @@ async def health_check() -> dict[str, str]:
|
||||
async def root() -> dict[str, str]:
|
||||
"""
|
||||
Root endpoint.
|
||||
|
||||
|
||||
Returns:
|
||||
API information
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles initialization of household registry and other startup tasks.
|
||||
This module should be called during application startup to register
|
||||
all household members.
|
||||
"""
|
||||
|
||||
from src.agents.biographer import register_biographer
|
||||
from src.agents.housekeeper import register_housekeeper
|
||||
from src.agents.librarian import register_librarian
|
||||
|
||||
@@ -4,7 +4,6 @@ Tool call tracking.
|
||||
Tracks which tools are recommended by the Steward versus which tools
|
||||
are actually used by Tatlock for debugging and analysis.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -19,11 +18,7 @@ class ToolCallTracker:
|
||||
to measure recommendation accuracy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
recommended_capabilities: list[str],
|
||||
conversation_id: Optional[str] = None
|
||||
):
|
||||
def __init__(self, recommended_capabilities: list[str], conversation_id: str | None = None):
|
||||
"""
|
||||
Initialize tool call tracker.
|
||||
|
||||
@@ -95,9 +90,7 @@ class ToolCallTracker:
|
||||
tools that were recommended but never used.
|
||||
"""
|
||||
# Normalize actual tool names to capabilities for comparison
|
||||
used_capabilities = {
|
||||
self._extract_capability(tool) for tool in self.actual_calls.keys()
|
||||
}
|
||||
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
|
||||
# Find tools that were recommended but not used
|
||||
unused_tools = self.recommended_capabilities - used_capabilities
|
||||
|
||||
@@ -128,9 +121,7 @@ class ToolCallTracker:
|
||||
"""
|
||||
total_calls = sum(len(durations) for durations in self.actual_calls.values())
|
||||
# Normalize actual tool names to capabilities for comparison
|
||||
used_capabilities = {
|
||||
self._extract_capability(tool) for tool in self.actual_calls.keys()
|
||||
}
|
||||
used_capabilities = {self._extract_capability(tool) for tool in self.actual_calls.keys()}
|
||||
unused = self.recommended_capabilities - used_capabilities
|
||||
|
||||
return {
|
||||
@@ -139,12 +130,8 @@ class ToolCallTracker:
|
||||
"tools_unused": list(unused),
|
||||
"total_calls": total_calls,
|
||||
"accuracy": {
|
||||
"recommended_and_used": len(
|
||||
self.recommended_capabilities & used_capabilities
|
||||
),
|
||||
"recommended_and_used": len(self.recommended_capabilities & used_capabilities),
|
||||
"recommended_but_unused": len(unused),
|
||||
"not_recommended_but_used": len(
|
||||
used_capabilities - self.recommended_capabilities
|
||||
),
|
||||
"not_recommended_but_used": len(used_capabilities - self.recommended_capabilities),
|
||||
},
|
||||
}
|
||||
|
||||
+23
-14
@@ -9,16 +9,16 @@ Enable via DEBUG=true environment variable.
|
||||
Traces are written to logs/traces/{trace_id}.json
|
||||
View with logs/traces/viewer.html
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.core.logging_config import get_logger
|
||||
|
||||
@@ -27,6 +27,7 @@ logger = get_logger(__name__)
|
||||
|
||||
class SpanType(str, Enum):
|
||||
"""Types of traced operations."""
|
||||
|
||||
ROUTER = "router"
|
||||
STEWARD = "steward"
|
||||
TATLOCK = "tatlock"
|
||||
@@ -36,6 +37,7 @@ class SpanType(str, Enum):
|
||||
|
||||
class SpanStatus(str, Enum):
|
||||
"""Span completion status."""
|
||||
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
|
||||
@@ -43,6 +45,7 @@ class SpanStatus(str, Enum):
|
||||
@dataclass
|
||||
class Span:
|
||||
"""A single traced operation."""
|
||||
|
||||
span_id: str
|
||||
name: str
|
||||
type: SpanType
|
||||
@@ -88,6 +91,7 @@ class Span:
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""Complete trace of a request."""
|
||||
|
||||
trace_id: str
|
||||
conversation_id: str | None
|
||||
user: str
|
||||
@@ -116,7 +120,9 @@ class Trace:
|
||||
"conversation_id": self.conversation_id,
|
||||
"user": self.user,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"total_duration_ms": round(self.total_duration_ms, 2) if self.total_duration_ms else None,
|
||||
"total_duration_ms": round(self.total_duration_ms, 2)
|
||||
if self.total_duration_ms
|
||||
else None,
|
||||
"status": self.status,
|
||||
"request": self.request,
|
||||
"response": self.response,
|
||||
@@ -132,6 +138,7 @@ _current_span: ContextVar[Span | None] = ContextVar("current_span", default=None
|
||||
def tracing_enabled() -> bool:
|
||||
"""Check if tracing is enabled (requires DEBUG=true)."""
|
||||
from src.core.config import config
|
||||
|
||||
return config.DEBUG
|
||||
|
||||
|
||||
@@ -163,7 +170,7 @@ def start_trace(
|
||||
trace_id=_generate_id("trace_"),
|
||||
conversation_id=conversation_id,
|
||||
user=user,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
timestamp=datetime.now(UTC),
|
||||
request=request,
|
||||
)
|
||||
_current_trace.set(trace)
|
||||
@@ -209,7 +216,7 @@ def start_span(
|
||||
span_id=_generate_id("span_"),
|
||||
name=name,
|
||||
type=span_type,
|
||||
start_time=datetime.now(timezone.utc),
|
||||
start_time=datetime.now(UTC),
|
||||
parent_id=parent.span_id if parent else None,
|
||||
metadata=metadata or {},
|
||||
details=details or {},
|
||||
@@ -254,7 +261,7 @@ def end_span(
|
||||
if not span:
|
||||
return
|
||||
|
||||
span.end_time = datetime.now(timezone.utc)
|
||||
span.end_time = datetime.now(UTC)
|
||||
span.status = status
|
||||
if error:
|
||||
span.error = error
|
||||
@@ -405,7 +412,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
if isinstance(part, ToolCallPart):
|
||||
tool_calls[part.tool_call_id] = {
|
||||
"name": part.tool_name,
|
||||
"args": part.args if hasattr(part, 'args') else {},
|
||||
"args": part.args if hasattr(part, "args") else {},
|
||||
}
|
||||
elif isinstance(msg, ModelRequest):
|
||||
for part in msg.parts:
|
||||
@@ -418,7 +425,7 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
name=tool_info["name"],
|
||||
type=SpanType.TOOL,
|
||||
start_time=parent_span.start_time, # Approximate
|
||||
end_time=parent_span.end_time or datetime.now(timezone.utc),
|
||||
end_time=parent_span.end_time or datetime.now(UTC),
|
||||
parent_id=parent_span.span_id,
|
||||
status=SpanStatus.OK,
|
||||
metadata={
|
||||
@@ -427,7 +434,9 @@ def add_tool_spans_from_messages(messages: list[Any], parent_span: Span | None =
|
||||
},
|
||||
details={
|
||||
"args": tool_info.get("args", {}),
|
||||
"result": part.content[:2000] if isinstance(part.content, str) else str(part.content)[:2000],
|
||||
"result": part.content[:2000]
|
||||
if isinstance(part.content, str)
|
||||
else str(part.content)[:2000],
|
||||
},
|
||||
)
|
||||
parent_span.children.append(span.span_id)
|
||||
|
||||
+17
-12
@@ -4,6 +4,8 @@ Trace viewer router.
|
||||
Serves the trace viewer UI and trace files when tracing is enabled.
|
||||
Only available when DEBUG=true.
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -66,12 +68,12 @@ async def list_traces(
|
||||
return {"traces": [], "total": 0}
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Calculate cutoff time if filtering by time
|
||||
cutoff_time = None
|
||||
if since_minutes:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(minutes=since_minutes)
|
||||
|
||||
# Get all trace files, sorted by modification time (newest first)
|
||||
trace_files = sorted(
|
||||
@@ -93,7 +95,7 @@ async def list_traces(
|
||||
trace_timestamp = data.get("timestamp")
|
||||
if cutoff_time and trace_timestamp:
|
||||
try:
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
|
||||
ts = datetime.fromisoformat(trace_timestamp.replace("Z", "+00:00"))
|
||||
if ts < cutoff_time:
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
@@ -109,15 +111,17 @@ async def list_traces(
|
||||
if search and search.lower() not in request_preview.lower():
|
||||
continue
|
||||
|
||||
traces.append({
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
})
|
||||
traces.append(
|
||||
{
|
||||
"trace_id": data.get("trace_id"),
|
||||
"timestamp": trace_timestamp,
|
||||
"user": data.get("user"),
|
||||
"status": trace_status,
|
||||
"total_duration_ms": data.get("total_duration_ms"),
|
||||
"span_count": len(data.get("spans", [])),
|
||||
"request_preview": request_preview[:100],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
|
||||
|
||||
@@ -145,6 +149,7 @@ async def get_trace(trace_id: str):
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(trace_path) as f:
|
||||
data = json.load(f)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
Reference in New Issue
Block a user