Configuration changes: - Replace pydantic-settings with OmegaConf for flexible config management - Support YAML config files via CONFIG_FILE environment variable - Support environment variables with STEWARD__SECTION__KEY format - Add config_schema.yaml as default configuration schema - Create pydantic models for each configuration section for type safety - Maintain backward compatibility via properties on Settings class - Add CONFIGURATION.md with comprehensive setup and usage guide - Update pyproject.toml to include config_schema.yaml in package data Build/deployment changes: - Replace pip with uv in Dockerfile for faster dependency installation - Create docker-compose.dev.yml for local development (build .) - Keep docker-compose.yml for production (uses ghcr.io/djw4/steward:latest) - Update .env.example with new STEWARD__* variable format This setup is designed for Kubernetes deployment: - Non-sensitive config goes in ConfigMap (config.yaml) - Secrets go in Secret resources (environment variables) - Single unified configuration system for all environments
247 lines
7.7 KiB
Python
247 lines
7.7 KiB
Python
"""Configuration management for Steward.
|
|
|
|
Supports configuration via:
|
|
1. YAML config file (passed via CONFIG_FILE env var)
|
|
2. Environment variables (STEWARD__SECTION__KEY=value format)
|
|
3. Default schema values
|
|
|
|
Environment variables take precedence over config file values.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from omegaconf import OmegaConf
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TelegramConfig(BaseModel):
|
|
"""Telegram bot configuration."""
|
|
|
|
bot_token: str = Field(default="", description="Telegram bot token")
|
|
allowed_user_ids: list[int] = Field(default_factory=list, description="Allowed user IDs")
|
|
group_ids: list[int] = Field(default_factory=list, description="Allowed group/channel IDs")
|
|
|
|
|
|
class OpenAIConfig(BaseModel):
|
|
"""OpenAI/LLM configuration."""
|
|
|
|
api_key: str = Field(default="", description="OpenAI API key")
|
|
base_url: str = Field(default="https://api.openai.com/v1", description="API base URL")
|
|
model: str = Field(default="gpt-4o", description="Model to use")
|
|
system_prompt: str = Field(
|
|
default=(
|
|
"You are Steward, a persistent, trustworthy AI-assisted personal operations platform. "
|
|
"You reduce cognitive load by observing, remembering, planning, and proposing actions. "
|
|
"You are conservative, transparent, and policy-aware. "
|
|
"Always explain your reasoning."
|
|
),
|
|
description="System prompt for the LLM",
|
|
)
|
|
|
|
|
|
class AnalysisConfig(BaseModel):
|
|
"""Analysis/proposal worker configuration."""
|
|
|
|
target_url: str = Field(default="", description="Target URL for analysis")
|
|
target_api_key: str = Field(default="", description="API key for target")
|
|
cron_hour: int = Field(default=8, description="Hour for cron schedule")
|
|
cron_minute: int = Field(default=0, description="Minute for cron schedule")
|
|
|
|
|
|
class MemoryConfig(BaseModel):
|
|
"""Memory/persistence configuration."""
|
|
|
|
thread_memory_path: str = Field(default="thread_memory.json", description="Thread memory path")
|
|
|
|
|
|
class ToolsConfig(BaseModel):
|
|
"""Tools/MCP configuration."""
|
|
|
|
mcp_server_url: str = Field(default="", description="MCP server URL")
|
|
mcp_server_api_key: str = Field(default="", description="MCP server API key")
|
|
|
|
|
|
class Settings(BaseModel):
|
|
"""Application settings with OmegaConf and pydantic integration."""
|
|
|
|
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
|
openai: OpenAIConfig = Field(default_factory=OpenAIConfig)
|
|
analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
|
|
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
|
|
|
class Config:
|
|
"""Pydantic config."""
|
|
|
|
arbitrary_types_allowed = True
|
|
|
|
# Compatibility properties for existing code
|
|
@property
|
|
def telegram_bot_token(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.telegram.bot_token
|
|
|
|
@property
|
|
def telegram_allowed_user_ids(self) -> list[int]:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.telegram.allowed_user_ids
|
|
|
|
@property
|
|
def telegram_group_ids(self) -> list[int]:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.telegram.group_ids
|
|
|
|
@property
|
|
def openai_api_key(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.openai.api_key
|
|
|
|
@property
|
|
def openai_base_url(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.openai.base_url
|
|
|
|
@property
|
|
def openai_model(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.openai.model
|
|
|
|
@property
|
|
def openai_system_prompt(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.openai.system_prompt
|
|
|
|
@property
|
|
def analysis_target_url(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.analysis.target_url
|
|
|
|
@property
|
|
def analysis_target_api_key(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.analysis.target_api_key
|
|
|
|
@property
|
|
def analysis_cron_hour(self) -> int:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.analysis.cron_hour
|
|
|
|
@property
|
|
def analysis_cron_minute(self) -> int:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.analysis.cron_minute
|
|
|
|
@property
|
|
def thread_memory_path(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.memory.thread_memory_path
|
|
|
|
@property
|
|
def mcp_server_url(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.tools.mcp_server_url
|
|
|
|
@property
|
|
def mcp_server_api_key(self) -> str:
|
|
"""Legacy property for backward compatibility."""
|
|
return self.tools.mcp_server_api_key
|
|
|
|
|
|
_settings_instance: Settings | None = None
|
|
|
|
|
|
def _load_config_from_file(config_path: str | Path) -> dict:
|
|
"""Load configuration from YAML file."""
|
|
config_path = Path(config_path)
|
|
if not config_path.exists():
|
|
logger.warning("Config file not found: %s", config_path)
|
|
return {}
|
|
|
|
logger.info("Loading config from %s", config_path)
|
|
cfg = OmegaConf.load(config_path)
|
|
return OmegaConf.to_container(cfg, resolve=True) or {}
|
|
|
|
|
|
def _load_config_from_env() -> dict:
|
|
"""Load configuration from environment variables (STEWARD__SECTION__KEY format)."""
|
|
cfg = {}
|
|
prefix = "STEWARD__"
|
|
|
|
for key, value in os.environ.items():
|
|
if not key.startswith(prefix):
|
|
continue
|
|
|
|
# Parse STEWARD__SECTION__KEY=value
|
|
parts = key[len(prefix) :].lower().split("__")
|
|
if len(parts) < 2:
|
|
continue
|
|
|
|
section = parts[0]
|
|
setting_key = "__".join(parts[1:])
|
|
|
|
if section not in cfg:
|
|
cfg[section] = {}
|
|
|
|
# Try to parse value as JSON first (for lists, etc.)
|
|
if isinstance(cfg[section], dict):
|
|
try:
|
|
import json
|
|
|
|
cfg[section][setting_key] = json.loads(value)
|
|
except (json.JSONDecodeError, ValueError):
|
|
cfg[section][setting_key] = value
|
|
|
|
return cfg
|
|
|
|
|
|
def get_settings() -> Settings:
|
|
"""Get or create the settings singleton.
|
|
|
|
Configuration is loaded in order of precedence (highest to lowest):
|
|
1. Environment variables (STEWARD__SECTION__KEY=value)
|
|
2. YAML config file (path via CONFIG_FILE env var)
|
|
3. Default values from schema
|
|
|
|
Returns:
|
|
Settings: The application settings.
|
|
"""
|
|
global _settings_instance
|
|
|
|
if _settings_instance is not None:
|
|
return _settings_instance
|
|
|
|
# Start with schema defaults
|
|
schema_path = Path(__file__).parent / "config_schema.yaml"
|
|
base_cfg = OmegaConf.load(schema_path)
|
|
|
|
# Merge in config file if specified
|
|
config_file = os.environ.get("CONFIG_FILE")
|
|
if config_file:
|
|
file_cfg = OmegaConf.create(_load_config_from_file(config_file))
|
|
base_cfg = OmegaConf.merge(base_cfg, file_cfg)
|
|
|
|
# Merge in environment variables (takes precedence)
|
|
env_cfg = OmegaConf.create(_load_config_from_env())
|
|
if env_cfg:
|
|
base_cfg = OmegaConf.merge(base_cfg, env_cfg)
|
|
|
|
# Convert to dict and create Settings instance
|
|
config_dict = OmegaConf.to_container(base_cfg, resolve=True) or {}
|
|
_settings_instance = Settings(**config_dict)
|
|
|
|
logger.info("Configuration loaded successfully")
|
|
logger.debug("Active configuration: %s", OmegaConf.to_yaml(base_cfg))
|
|
|
|
return _settings_instance
|
|
|
|
|
|
def reload_settings() -> Settings:
|
|
"""Reload configuration from source (mainly for testing)."""
|
|
global _settings_instance
|
|
_settings_instance = None
|
|
return get_settings()
|