from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from dotenv import load_dotenv BACKEND_ROOT = Path(__file__).resolve().parents[1] DEFAULT_ENV_FILE = BACKEND_ROOT / ".env" def _as_bool(value: str | None, default: bool) -> bool: if value is None: return default normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False raise ValueError(f"Invalid boolean configuration value: {value!r}") def _as_int(name: str, value: str | None, default: int) -> int: if value is None: return default parsed = int(value) if parsed < 0: raise ValueError(f"{name} must be non-negative") return parsed def _as_float(name: str, value: str | None, default: float) -> float: if value is None: return default parsed = float(value) if parsed <= 0: raise ValueError(f"{name} must be positive") return parsed @dataclass(frozen=True, slots=True) class Settings: """Runtime settings with the API key deliberately hidden from repr output.""" llm_provider: str = "auto" openai_api_key: str | None = field(default=None, repr=False) openai_base_url: str | None = None openai_model: str = "gpt-4o-mini" embedding_provider: str = "tei" embedding_base_url: str = "http://127.0.0.1:8081" embedding_model: str = "BAAI/bge-m3" embedding_dimensions: int = 1024 embedding_timeout_seconds: float = 30.0 embedding_batch_size: int = 32 openai_timeout_seconds: float = 30.0 openai_max_retries: int = 2 light_opt_rate_limit: int = 20 light_opt_rate_window_seconds: float = 3600.0 structured_output_retries: int = 1 structured_output_mode: str = "json_schema" fallback_to_rules: bool = True intent_router_mode: str = "off" intent_model: str | None = None knowledge_admin_token: str | None = field(default=None, repr=False) database_url: str | None = field(default=None, repr=False) deep_max_questions: int = 6 deep_min_questions: int = 2 deep_gap_threshold: float = 5.0 @property def use_openai(self) -> bool: if self.llm_provider == "openai": if not self.openai_api_key: raise ValueError("OPENAI_API_KEY is required when LLM provider is openai") return True if self.llm_provider == "volcengine": if not self.openai_api_key: raise ValueError( "VOLCENGINE_API_KEY is required when LLM provider is volcengine" ) return True if self.llm_provider == "rule": return False if self.llm_provider != "auto": raise ValueError( "RESUME_AGENT_LLM_PROVIDER must be auto, openai, volcengine, or rule" ) return bool(self.openai_api_key) def load_settings(env_file: str | Path | None = None) -> Settings: selected_file = Path( env_file or os.getenv("RESUME_AGENT_ENV_FILE", str(DEFAULT_ENV_FILE)) ) load_dotenv(selected_file, override=False) mode = os.getenv("OPENAI_STRUCTURED_OUTPUT_MODE", "json_schema").strip().lower() if mode not in {"json_schema", "json_object"}: raise ValueError( "OPENAI_STRUCTURED_OUTPUT_MODE must be json_schema or json_object" ) provider = os.getenv("RESUME_AGENT_LLM_PROVIDER", "auto").strip().lower() if provider == "volcengine": llm_api_key = os.getenv("VOLCENGINE_API_KEY") or None llm_base_url = os.getenv("VOLCENGINE_BASE_URL") or None llm_model = os.getenv("VOLCENGINE_MODEL", "").strip() else: llm_api_key = os.getenv("OPENAI_API_KEY") or None llm_base_url = os.getenv("OPENAI_BASE_URL") or None llm_model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip() intent_mode = os.getenv("RESUME_AGENT_INTENT_ROUTER_MODE", "off").strip().lower() if intent_mode not in {"off", "shadow", "on"}: raise ValueError("RESUME_AGENT_INTENT_ROUTER_MODE must be off, shadow, or on") settings = Settings( llm_provider=provider, openai_api_key=llm_api_key, openai_base_url=llm_base_url, openai_model=llm_model, embedding_provider=os.getenv("EMBEDDING_PROVIDER", "tei").strip().lower(), embedding_base_url=os.getenv("EMBEDDING_BASE_URL", "http://127.0.0.1:8081").rstrip("/"), embedding_model=os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3").strip(), embedding_dimensions=_as_int( "EMBEDDING_DIMENSIONS", os.getenv("EMBEDDING_DIMENSIONS"), 1024 ), embedding_timeout_seconds=_as_float( "EMBEDDING_TIMEOUT_SECONDS", os.getenv("EMBEDDING_TIMEOUT_SECONDS"), 30.0 ), embedding_batch_size=_as_int( "EMBEDDING_BATCH_SIZE", os.getenv("EMBEDDING_BATCH_SIZE"), 32 ), openai_timeout_seconds=_as_float( "OPENAI_TIMEOUT_SECONDS", os.getenv("OPENAI_TIMEOUT_SECONDS"), 30.0 ), openai_max_retries=_as_int( "OPENAI_MAX_RETRIES", os.getenv("OPENAI_MAX_RETRIES"), 2 ), light_opt_rate_limit=_as_int( "RESUME_AGENT_LIGHT_OPT_RATE_LIMIT", os.getenv("RESUME_AGENT_LIGHT_OPT_RATE_LIMIT"), 20, ), light_opt_rate_window_seconds=_as_float( "RESUME_AGENT_LIGHT_OPT_RATE_WINDOW_SECONDS", os.getenv("RESUME_AGENT_LIGHT_OPT_RATE_WINDOW_SECONDS"), 3600.0, ), structured_output_retries=_as_int( "OPENAI_STRUCTURED_OUTPUT_RETRIES", os.getenv("OPENAI_STRUCTURED_OUTPUT_RETRIES"), 1, ), structured_output_mode=mode, fallback_to_rules=_as_bool( os.getenv("RESUME_AGENT_LLM_FALLBACK_TO_RULES"), True ), intent_router_mode=intent_mode, intent_model=os.getenv("RESUME_AGENT_INTENT_MODEL", "").strip() or None, knowledge_admin_token=os.getenv("KNOWLEDGE_ADMIN_TOKEN") or None, database_url=os.getenv("DATABASE_URL") or None, deep_max_questions=_as_int( "RESUME_AGENT_DEEP_MAX_QUESTIONS", os.getenv("RESUME_AGENT_DEEP_MAX_QUESTIONS"), 6, ), deep_min_questions=_as_int( "RESUME_AGENT_DEEP_MIN_QUESTIONS", os.getenv("RESUME_AGENT_DEEP_MIN_QUESTIONS"), 2, ), deep_gap_threshold=_as_float( "RESUME_AGENT_DEEP_GAP_THRESHOLD", os.getenv("RESUME_AGENT_DEEP_GAP_THRESHOLD"), 5.0, ), ) if settings.embedding_provider not in {"tei", "openai", "hash"}: raise ValueError("EMBEDDING_PROVIDER must be tei, openai, or hash") if not settings.embedding_model: raise ValueError("EMBEDDING_MODEL cannot be blank") if settings.embedding_provider == "tei" and not settings.embedding_base_url: raise ValueError("EMBEDDING_BASE_URL cannot be blank when EMBEDDING_PROVIDER is tei") if settings.embedding_dimensions < 1: raise ValueError("EMBEDDING_DIMENSIONS must be positive") if settings.embedding_batch_size < 1: raise ValueError("EMBEDDING_BATCH_SIZE must be positive") if settings.deep_max_questions < settings.deep_min_questions: raise ValueError( "RESUME_AGENT_DEEP_MAX_QUESTIONS must be at least " "RESUME_AGENT_DEEP_MIN_QUESTIONS" ) if settings.deep_max_questions < 1: raise ValueError("RESUME_AGENT_DEEP_MAX_QUESTIONS must be positive") if not settings.openai_model: model_setting = "VOLCENGINE_MODEL" if provider == "volcengine" else "OPENAI_MODEL" raise ValueError(f"{model_setting} cannot be blank") return settings