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" openai_timeout_seconds: float = 30.0 openai_max_retries: int = 2 structured_output_retries: int = 1 structured_output_mode: str = "json_schema" fallback_to_rules: bool = True @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 == "rule": return False if self.llm_provider != "auto": raise ValueError("RESUME_AGENT_LLM_PROVIDER must be auto, openai, 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() settings = Settings( llm_provider=provider, openai_api_key=os.getenv("OPENAI_API_KEY") or None, openai_base_url=os.getenv("OPENAI_BASE_URL") or None, openai_model=os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip(), 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 ), 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 ), ) if not settings.openai_model: raise ValueError("OPENAI_MODEL cannot be blank") return settings