130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""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 typing import Annotated
|
|
|
|
from pydantic import Field, field_validator
|
|
from pydantic_settings import BaseSettings, NoDecode, 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
|
|
# `NoDecode` tells pydantic-settings to NOT JSON-parse the env var value
|
|
# before passing it to our `_split_cors` validator. Without it, the value
|
|
# `CORS_ORIGINS=http://a,http://b` would be rejected as invalid JSON.
|
|
cors_origins: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["*"])
|
|
|
|
# === 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")
|
|
|
|
@field_validator("cors_origins", mode="before")
|
|
@classmethod
|
|
def _split_cors(cls, v):
|
|
if isinstance(v, str):
|
|
return [item.strip() for item in v.split(",") if item.strip()]
|
|
return v
|
|
|
|
|
|
@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()
|