import os from pathlib import Path from typing import Any import yaml from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class InvestibookSettings(BaseSettings): """Investibook configuration loaded from YAML file and environment variables. Priority: Environment variables > YAML file > defaults Environment variables use the prefix INVESTIBOOK_, e.g.: - INVESTIBOOK_AIOPS_API_URL - INVESTIBOOK_S3_BUCKET - INVESTIBOOK_CLINIC_API_KEY """ # Guardian API aiops_api_url: str = Field( default="", description="Guardian API base URL (e.g., http://localhost:9090)" ) # S3 s3_bucket: str = Field(default="", description="S3 bucket name for analysis files") aws_region: str = Field(default="us-west-2", description="AWS region for S3 bucket") # Note: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_PROFILE are read by boto3 natively # Workspace export to S3 export_s3_prefix: str = Field( default="investibook_service/export_workspaces", description="S3 key prefix for workspace export archives. Env: INVESTIBOOK_EXPORT_S3_PREFIX", ) # Clinic clinic_endpoint: str = Field( default="https://clinic.pingcap.com", description="Clinic HTTP API endpoint" ) clinic_api_key: str = Field( default="", description="Clinic API key for authentication" ) clinic_cookie: str = Field( default="", description="Clinic cookie for local debugging (alternative to API key)", ) clinic_csrf_token: str = Field( default="", description="Clinic CSRF token for local debugging (used with cookie)", ) # Jira jira_base_url: str = Field( default="https://tidb.atlassian.net", description="Jira base URL" ) jira_username: str = Field(default="", description="Jira username (email)") jira_api_token: str = Field( default="", description="Jira API token for authentication" ) skip_jira_comments: bool = Field( default=False, description="If true, skip fetching/writing Jira comments during issue prepare (no comments.json / comments_summary_ai.md). Env: INVESTIBOOK_SKIP_JIRA_COMMENTS", ) # Alertstore (shares the DevOps gateway; ALT-* alerts) devops_base_url: str = Field( default_factory=lambda: ( os.environ.get("DEVOPS_BASE_URL") or os.environ.get("DEVOPS_BASE_DOMAIN") or "https://prod.devops.pingcap.com" ), description="DevOps gateway base URL for alertstore", ) devops_api_token: str = Field( default_factory=lambda: ( os.environ.get("DEVOPS_API_KEY") or os.environ.get("DEVOPS_API_TOKEN") or "" ), description="DevOps gateway API key (X-API-KEY) for alertstore", ) # LLM for comment summary (direct OpenAI call, no ACP agent) openai_api_key: str = Field( default="", description="OpenAI API key for comment summarization. Env: INVESTIBOOK_OPENAI_API_KEY", ) openai_model: str = Field( default="gpt-4o", description="OpenAI model name for comment summarization", ) model_config = SettingsConfigDict( env_prefix="INVESTIBOOK_", env_file=".env", env_file_encoding="utf-8", extra="ignore", ) def load_settings() -> InvestibookSettings: """Load settings from YAML file and environment variables. Looks for config file in the following locations (in order): 1. INVESTIBOOK_CONFIG_FILE environment variable 2. ../config.yaml (parent directory, for backend/) 3. ../investibook.yaml (parent directory) 4. ./investibook.yaml (current directory) 5. ./config/investibook.yaml 6. ~/.investibook/config.yaml Priority: Environment variables > YAML file > defaults Returns: InvestibookSettings instance with loaded configuration """ # First create settings from env vars and defaults settings = InvestibookSettings() # Try to find and load YAML config file config_paths = [ Path(os.environ.get("INVESTIBOOK_CONFIG_FILE", "")), Path("../config.yaml"), # Parent directory (for backend/) Path("../investibook.yaml"), # Parent directory Path("investibook.yaml"), Path("config/investibook.yaml"), Path.home() / ".investibook" / "config.yaml", ] yaml_data: dict[str, Any] = {} loaded_path = None for config_path in config_paths: if config_path.exists() and config_path.is_file(): try: with open(config_path, "r") as f: yaml_data = yaml.safe_load(f) or {} loaded_path = config_path print(f"Loaded configuration from {config_path}") break except Exception as e: print(f"Warning: Failed to load config from {config_path}: {e}") # Merge YAML values only for fields that weren't set via environment # Environment variables use the INVESTIBOOK_ prefix and are already loaded if yaml_data and loaded_path: for field_name, yaml_value in yaml_data.items(): # Check if env var exists for this field env_var_name = f"INVESTIBOOK_{field_name.upper()}" if env_var_name not in os.environ: # No env var, so use YAML value if current value is default if hasattr(settings, field_name): setattr(settings, field_name, yaml_value) return settings settings = load_settings()