"""Centralized configuration via Pydantic Settings. All env vars are loaded from .env once at import time and validated. Import the singleton `settings` everywhere — never read os.environ directly. """ from __future__ import annotations from enum import Enum from functools import lru_cache from pathlib import Path from pydantic import Field, HttpUrl, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class LlmRole(str, Enum): """Logical role a caller asks for. Routing decides which model serves it.""" REASONING = "reasoning" # critical: extraction, NLI, verdict, wiki FAST = "fast" # mass processing (currently disabled) VISION = "vision" # multimodal (currently disabled) class Settings(BaseSettings): """Top-level config. Validated at startup, immutable thereafter.""" model_config = SettingsConfigDict( env_file=Path(__file__).parent.parent / ".env", env_file_encoding="utf-8", extra="ignore", ) # ---- LLM router --------------------------------------------------------- llm_router_url: str = Field(default="http://localhost:14011") llm_router_api_key: str = Field(default="") llm_vllm_url: str = Field(default="http://localhost:14001") llm_llamacpp_urls: str = Field(default="") # comma-separated # ---- Models ------------------------------------------------------------- model_reasoning: str = Field(default="Qwen3.5-397B-A17B") model_reasoning_backend: str = Field(default="llamacpp") model_fast: str = Field(default="qwen3.5") model_fast_backend: str = Field(default="vllm") model_fast_enabled: bool = Field(default=False) model_vision: str = Field(default="gemma-3-27b-it") model_vision_url: str = Field(default="") model_vision_enabled: bool = Field(default=False) # ---- Embeddings --------------------------------------------------------- embedding_url: str = Field(default="http://10.11.10.15:8200") embedding_api_key: str = Field(default="") embedding_model: str = Field(default="BAAI/bge-m3") embedding_dim: int = Field(default=1024) embedding_max_tokens: int = Field(default=8192) # ---- Reranker ----------------------------------------------------------- reranker_url: str = Field(default="http://10.11.10.15:8100") reranker_api_key: str = Field(default="") reranker_model: str = Field(default="BAAI/bge-reranker-v2-m3") # ---- Atomic ------------------------------------------------------------- atomic_url: str = Field(default="http://localhost:8080") atomic_token: str = Field(default="") # ---- Postgres ----------------------------------------------------------- postgres_user: str = Field(default="atomic") postgres_password: str = Field(default="atomic_dev_changeme") postgres_db: str = Field(default="atomic") postgres_port: int = Field(default=5434) postgres_host: str = Field( default="postgres", description="Hostname for direct PG connection (Docker: 'postgres', host: 'localhost')", ) postgres_internal_port: int = Field( default=5432, description="Port inside the Docker network (external is postgres_port)", ) # ---- Verification cache -------------------------------------------------- verification_cache_ttl_days: int = Field( default=30, description="How long cached verification entries live before auto-expiry", ) verification_cache_max_payload_kb: int = Field( default=64, description="Reject POST /v1/verification_cache with payloads above this cap", ) # ---- Analysis atom tier policy ------------------------------------------ # These are read live from the AI platform dashboard via RuntimeConfigClient # (keys: brain.atom.silver_ttl_days, brain.atom.bronze_ttl_days, # brain.atom.confidence_silver_threshold). The values below are fallbacks # used at startup until the first dashboard poll completes (~30s). atom_silver_ttl_days: int = Field( default=90, description="TTL for LLM-cached analysis atoms (silver tier)", ) atom_bronze_ttl_days: int = Field( default=30, description="TTL for low-confidence atoms (never served, kept for audit)", ) atom_confidence_silver_threshold: float = Field( default=60.0, description="LLM confidence ≥ this stores atom as silver, else bronze", ) # ---- Logging ------------------------------------------------------------ log_level: str = Field(default="INFO") # ---- Runtime config (live polling from AI platform dashboard) ----------- dashboard_url: str | None = Field( default=None, description=( "Optional dashboard base URL (e.g. http://didiAI-dashboard:51300). " "When set, RuntimeConfigClient polls /api/config every 30s for live " "overrides on atom_* and log_level." ), ) # ---- Computed ----------------------------------------------------------- @property def postgres_dsn(self) -> str: """Async-compatible DSN for direct asyncpg connections.""" return ( f"postgresql://{self.postgres_user}:{self.postgres_password}" f"@{self.postgres_host}:{self.postgres_internal_port}/{self.postgres_db}" ) @property def llamacpp_urls_list(self) -> list[str]: return [u.strip() for u in self.llm_llamacpp_urls.split(",") if u.strip()] def model_for(self, role: LlmRole) -> tuple[str, str] | None: """Return (model_id, backend_hint) for a logical role, or None if disabled.""" if role == LlmRole.REASONING: return (self.model_reasoning, self.model_reasoning_backend) if role == LlmRole.FAST and self.model_fast_enabled: return (self.model_fast, self.model_fast_backend) if role == LlmRole.VISION and self.model_vision_enabled: return (self.model_vision, "external") return None @field_validator("log_level") @classmethod def _validate_log_level(cls, v: str) -> str: v = v.upper() if v not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}: raise ValueError(f"invalid log_level: {v}") return v @lru_cache(maxsize=1) def get_settings() -> Settings: """Singleton accessor. Cached so .env is parsed only once per process.""" return Settings() # Convenience: most code can `from shared.config import settings` settings = get_settings()