"""Runtime settings (environment variables, `AIA_` prefix). Never log `settings.model_dump()` — it contains secrets.""" 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): model_config = SettingsConfigDict(env_file=(".env",), env_file_encoding="utf-8", extra="ignore") app_env: str = Field("development", alias="APP_ENV") site_url: str = Field("https://www.ai-atlas.co", alias="AIA_SITE_URL") database_url: str = Field("postgresql+asyncpg://aiatlas:aiatlas@127.0.0.1:5432/aiatlas", alias="DATABASE_URL") redis_url: str = Field("redis://127.0.0.1:6379/5", alias="REDIS_URL") data_dir: Path = Field(Path("./data"), alias="AIA_DATA_DIR") api_host: str = Field("127.0.0.1", alias="AIA_API_HOST") api_port: int = Field(8321, alias="AIA_API_PORT") admin_token: str = Field("", alias="AIA_ADMIN_TOKEN") log_json: bool = Field(True, alias="AIA_LOG_JSON") tz: str = Field("America/Toronto", alias="AIA_TZ") # Crawler user_agent: str = Field("AIAtlasBot/0.1 (+https://www.ai-atlas.co/bot; contact@spboucher.ai)", alias="AIA_USER_AGENT") http_timeout_s: float = Field(45.0, alias="AIA_HTTP_TIMEOUT") max_body_bytes: int = Field(25 * 1024 * 1024, alias="AIA_MAX_BODY_BYTES") default_rate_per_min: int = Field(30, alias="AIA_DEFAULT_RATE_PER_MIN") respect_robots: bool = Field(True, alias="AIA_RESPECT_ROBOTS") fetch_concurrency: int = Field(8, alias="AIA_FETCH_CONCURRENCY") # Escalation transports (optional; never required) scrapfly_api_key: str = Field("", alias="SCRAPFLY_API_KEY") firecrawl_api_key: str = Field("", alias="FIRECRAWL_API_KEY") browser_enabled: bool = Field(False, alias="AIA_BROWSER_ENABLED") # Local LLM factory (OpenAI-compatible endpoint, e.g. MacLustr llm-api.io). Optional. llm_base_url: str = Field("", alias="AIA_LLM_BASE_URL") llm_api_key: str = Field("", alias="AIA_LLM_API_KEY") llm_small_model: str = Field("qwen3-4b-instruct-2507-4bit", alias="AIA_LLM_SMALL_MODEL") llm_medium_model: str = Field("qwen3.6-35b-a3b-4bit", alias="AIA_LLM_MEDIUM_MODEL") llm_large_model: str = Field("qwen3.8-27b-4bit", alias="AIA_LLM_LARGE_MODEL") llm_timeout_s: float = Field(600.0, alias="AIA_LLM_TIMEOUT") llm_enabled: bool = Field(True, alias="AIA_LLM_ENABLED") embedding_model: str = Field("qwen3-embedding-0.6b-8bit", alias="AIA_EMBEDDING_MODEL") embedding_dim: int = Field(1024, alias="AIA_EMBEDDING_DIM") # Scheduler scheduler_tick_s: int = Field(30, alias="AIA_SCHEDULER_TICK_S") worker_concurrency: int = Field(4, alias="AIA_WORKER_CONCURRENCY") backup_cron: str = Field("40 4 * * *", alias="AIA_BACKUP_CRON") @property def raw_dir(self) -> Path: return self.data_dir / "raw" @property def text_dir(self) -> Path: return self.data_dir / "text" @property def logs_dir(self) -> Path: return self.data_dir / "logs" @property def backups_dir(self) -> Path: return self.data_dir / "backups" @property def cache_dir(self) -> Path: return self.data_dir / "cache" @property def sync_database_url(self) -> str: return self.database_url.replace("+asyncpg", "") @property def llm_available(self) -> bool: return bool(self.llm_enabled and self.llm_base_url and self.llm_api_key) def ensure_dirs(self) -> None: for d in (self.raw_dir, self.text_dir, self.logs_dir, self.backups_dir, self.cache_dir, self.data_dir / "seed"): d.mkdir(parents=True, exist_ok=True) @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()