"""Application configuration loaded from environment variables / settings DB. Settings are layered: 1. Defaults defined in this module. 2. Overrides from environment variables (or `.env` file). 3. Runtime overrides from the `settings` table (loaded on startup and cached). The Settings class below is a Pydantic-Settings model — it only handles (1) and (2). The runtime DB overrides are managed by `app.core.settings_service`. """ from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Layered configuration for the AI-RPG backend.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # === Application === app_name: str = "AI-RPG" app_version: str = "1.0.0" debug: bool = False log_level: str = "INFO" secret_key: str = "change-me-in-production-please-32-bytes-long" jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 # 24 hours refresh_token_expire_minutes: int = 60 * 24 * 7 # 7 days # Comma-separated list of allowed CORS origins, e.g. # CORS_ORIGINS=http://localhost:8080,http://localhost:5173 # Special value "*" allows all origins. # Use `settings.cors_origins_list` to access the parsed list. cors_origins: str = "*" # === Admin setup === admin_setup_token: str = "" # if empty, will be auto-generated and stored in DB # === Database === database_url: str = "postgresql+asyncpg://airpg:airpg@localhost:5432/airpg" database_url_sync: str = "postgresql+psycopg2://airpg:airpg@localhost:5432/airpg" db_pool_size: int = 10 db_max_overflow: int = 20 db_echo: bool = False # === Qdrant === qdrant_url: str = "http://localhost:6333" qdrant_api_key: str = "" qdrant_collection_prefix: str = "" qdrant_timeout: float = 30.0 # === LLM (defaults; runtime overrides in `settings` table) === llm_api_url: str = "http://localhost:11434/v1" llm_api_key: str = "" llm_model: str = "qwen2.5-7b-instruct" llm_temperature_orchestrator: float = 0.7 llm_temperature_writer: float = 0.85 llm_max_tokens: int = 2048 llm_timeout_seconds: int = 60 # === Embeddings === embeddings_provider: str = "offline_hash" # "offline_hash" | "openai" embeddings_api_url: str = "" embeddings_api_key: str = "" embeddings_model: str = "text-embedding-3-small" embeddings_dimension: int = 256 # for offline_hash; will be probed for openai embeddings_timeout_seconds: int = 30 embeddings_batch_size: int = 32 embeddings_cache_ttl_seconds: int = 300 embeddings_max_text_chars: int = 4000 # === Context manager === context_guaranteed_messages: int = 10 context_compression_threshold_messages: int = 20 context_compression_threshold_tokens: int = 6000 context_scene_text_truncate_tokens: int = 500 context_auto_rag_on_entity_mention: bool = False context_safety_margin_tokens: int = 500 llm_context_window_tokens: int = 8192 # for tokenizer-based budgeting # === Game === game_deferred_triggers_enabled: bool = True game_max_substeps_per_iteration: int = 8 game_max_suggested_actions: int = 3 # === UI === ui_page_title: str = "AI-RPG" ui_favicon_url: str = "/icon.png" ui_logo_url: str = "/icon.png" ui_og_image_url: str = "" # === Storage === data_dir: str = "/home/z/my-project/ai-rpg/data" assets_dir: str = "" # computed in __init__ max_upload_size_bytes: int = 1024 * 1024 # 1 MB def __init__(self, **values): super().__init__(**values) if not self.assets_dir: self.assets_dir = str(Path(self.data_dir) / "assets") @property def cors_origins_list(self) -> list[str]: """Parse `cors_origins` (comma-separated string) into a list. Whitespace is stripped from each entry, empty entries are dropped. Returns `["*"]` if the field is empty or just whitespace. """ if not self.cors_origins or not self.cors_origins.strip(): return ["*"] return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @lru_cache(maxsize=1) def get_settings() -> Settings: """Cached settings instance. Use as the single source of truth for env config.""" return Settings() def reload_settings() -> Settings: """Force reload settings (used in tests).""" get_settings.cache_clear() return get_settings()