diff --git a/config.yaml b/config.yaml new file mode 100644 index 00000000..9de79618 --- /dev/null +++ b/config.yaml @@ -0,0 +1,33 @@ +# OpenRAG Configuration File +# This file allows you to configure OpenRAG settings. +# Environment variables will override these settings unless edited is true. + +# Track if this config has been manually edited (prevents env var overrides) +edited: false + +# Model provider configuration +provider: + # Supported providers: "openai", "anthropic", "azure", etc. + model_provider: "openai" + # API key for the model provider (can also be set via OPENAI_API_KEY env var) + api_key: "" + +# Knowledge base and document processing configuration +knowledge: + # Embedding model for vector search + embedding_model: "text-embedding-3-small" + # Text chunk size for document processing + chunk_size: 1000 + # Overlap between chunks + chunk_overlap: 200 + # Enable OCR for image processing + ocr: true + # Enable picture descriptions using vision models + picture_descriptions: false + +# AI agent configuration +agent: + # Language model for the chat agent + llm_model: "gpt-4o-mini" + # System prompt for the agent + system_prompt: "You are a helpful AI assistant with access to a knowledge base. Answer questions based on the provided context." diff --git a/docs/configuration.md b/docs/configuration.md index fcc76f37..453d6d1c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,37 @@ # Configuration -OpenRAG uses environment variables for configuration. Copy `.env.example` to `.env` and populate with your values: +OpenRAG supports multiple configuration methods with the following priority: + +1. **Environment Variables** (highest priority) +2. **Configuration File** (`config.yaml`) +3. **Langflow Flow Settings** (runtime override) +4. **Default Values** (fallback) + +## Configuration File + +Create a `config.yaml` file in the project root to configure OpenRAG: + +```yaml +# OpenRAG Configuration File +provider: + model_provider: "openai" # openai, anthropic, azure, etc. + api_key: "your-api-key" # or use OPENAI_API_KEY env var + +knowledge: + embedding_model: "text-embedding-3-small" + chunk_size: 1000 + chunk_overlap: 200 + ocr: true + picture_descriptions: false + +agent: + llm_model: "gpt-4o-mini" + system_prompt: "You are a helpful AI assistant..." +``` + +## Environment Variables + +Environment variables will override configuration file settings. You can still use `.env` files: ```bash cp .env.example .env @@ -8,20 +39,20 @@ cp .env.example .env ## Required Variables -| Variable | Description | -|----------|-------------| -| `OPENAI_API_KEY` | Your OpenAI API key | -| `OPENSEARCH_PASSWORD` | Password for OpenSearch admin user | -| `LANGFLOW_SUPERUSER` | Langflow admin username | -| `LANGFLOW_SUPERUSER_PASSWORD` | Langflow admin password | -| `LANGFLOW_CHAT_FLOW_ID` | ID of your Langflow chat flow | -| `LANGFLOW_INGEST_FLOW_ID` | ID of your Langflow ingestion flow | -| `NUDGES_FLOW_ID` | ID of your Langflow nudges/suggestions flow | +| Variable | Description | +| ----------------------------- | ------------------------------------------- | +| `OPENAI_API_KEY` | Your OpenAI API key | +| `OPENSEARCH_PASSWORD` | Password for OpenSearch admin user | +| `LANGFLOW_SUPERUSER` | Langflow admin username | +| `LANGFLOW_SUPERUSER_PASSWORD` | Langflow admin password | +| `LANGFLOW_CHAT_FLOW_ID` | ID of your Langflow chat flow | +| `LANGFLOW_INGEST_FLOW_ID` | ID of your Langflow ingestion flow | +| `NUDGES_FLOW_ID` | ID of your Langflow nudges/suggestions flow | ## Ingestion Configuration -| Variable | Description | -|----------|-------------| +| Variable | Description | +| ------------------------------ | ------------------------------------------------------ | | `DISABLE_INGEST_WITH_LANGFLOW` | Disable Langflow ingestion pipeline (default: `false`) | - `false` or unset: Uses Langflow pipeline (upload → ingest → delete) @@ -29,15 +60,44 @@ cp .env.example .env ## Optional Variables -| Variable | Description | -|----------|-------------| -| `LANGFLOW_PUBLIC_URL` | Public URL for Langflow (default: `http://localhost:7860`) | -| `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth authentication | -| `MICROSOFT_GRAPH_OAUTH_CLIENT_ID` / `MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET` | Microsoft OAuth | -| `WEBHOOK_BASE_URL` | Base URL for webhook endpoints | -| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | AWS integrations | -| `SESSION_SECRET` | Session management (default: auto-generated, change in production) | -| `LANGFLOW_KEY` | Explicit Langflow API key (auto-generated if not provided) | -| `LANGFLOW_SECRET_KEY` | Secret key for Langflow internal operations | +| Variable | Description | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `LANGFLOW_PUBLIC_URL` | Public URL for Langflow (default: `http://localhost:7860`) | +| `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth authentication | +| `MICROSOFT_GRAPH_OAUTH_CLIENT_ID` / `MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET` | Microsoft OAuth | +| `WEBHOOK_BASE_URL` | Base URL for webhook endpoints | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | AWS integrations | +| `SESSION_SECRET` | Session management (default: auto-generated, change in production) | +| `LANGFLOW_KEY` | Explicit Langflow API key (auto-generated if not provided) | +| `LANGFLOW_SECRET_KEY` | Secret key for Langflow internal operations | + +## OpenRAG Configuration Variables + +These environment variables override settings in `config.yaml`: + +### Provider Settings + +| Variable | Description | Default | +| ------------------ | ---------------------------------------- | -------- | +| `MODEL_PROVIDER` | Model provider (openai, anthropic, etc.) | `openai` | +| `PROVIDER_API_KEY` | API key for the model provider | | +| `OPENAI_API_KEY` | OpenAI API key (backward compatibility) | | + +### Knowledge Settings + +| Variable | Description | Default | +| ------------------------------ | --------------------------------------- | ------------------------ | +| `EMBEDDING_MODEL` | Embedding model for vector search | `text-embedding-3-small` | +| `CHUNK_SIZE` | Text chunk size for document processing | `1000` | +| `CHUNK_OVERLAP` | Overlap between chunks | `200` | +| `OCR_ENABLED` | Enable OCR for image processing | `true` | +| `PICTURE_DESCRIPTIONS_ENABLED` | Enable picture descriptions | `false` | + +### Agent Settings + +| Variable | Description | Default | +| --------------- | --------------------------------- | ------------------------ | +| `LLM_MODEL` | Language model for the chat agent | `gpt-4o-mini` | +| `SYSTEM_PROMPT` | System prompt for the agent | Default assistant prompt | See `.env.example` for a complete list with descriptions, and `docker-compose*.yml` for runtime usage. diff --git a/src/api/settings.py b/src/api/settings.py index 0f49f85e..4efdc2ac 100644 --- a/src/api/settings.py +++ b/src/api/settings.py @@ -6,6 +6,8 @@ from config.settings import ( LANGFLOW_INGEST_FLOW_ID, LANGFLOW_PUBLIC_URL, clients, + get_openrag_config, + config_manager, ) logger = get_logger(__name__) @@ -15,12 +17,34 @@ logger = get_logger(__name__) async def get_settings(request, session_manager): """Get application settings""" try: + openrag_config = get_openrag_config() + + provider_config = openrag_config.provider + knowledge_config = openrag_config.knowledge + agent_config = openrag_config.agent # Return public settings that are safe to expose to frontend settings = { "langflow_url": LANGFLOW_URL, "flow_id": LANGFLOW_CHAT_FLOW_ID, "ingest_flow_id": LANGFLOW_INGEST_FLOW_ID, "langflow_public_url": LANGFLOW_PUBLIC_URL, + "edited": openrag_config.edited, + # OpenRAG configuration + "provider": { + "model_provider": provider_config.model_provider, + # Note: API key is not exposed for security + }, + "knowledge": { + "embedding_model": knowledge_config.embedding_model, + "chunk_size": knowledge_config.chunk_size, + "chunk_overlap": knowledge_config.chunk_overlap, + "ocr": knowledge_config.ocr, + "picture_descriptions": knowledge_config.picture_descriptions, + }, + "agent": { + "llm_model": agent_config.llm_model, + "system_prompt": agent_config.system_prompt, + }, } # Only expose edit URLs when a public URL is configured @@ -35,7 +59,7 @@ async def get_settings(request, session_manager): ) # Fetch ingestion flow configuration to get actual component defaults - if LANGFLOW_INGEST_FLOW_ID: + if LANGFLOW_INGEST_FLOW_ID and openrag_config.edited: try: response = await clients.langflow_request( "GET", @@ -45,11 +69,12 @@ async def get_settings(request, session_manager): flow_data = response.json() # Extract component defaults (ingestion-specific settings only) + # Start with configured defaults ingestion_defaults = { - "chunkSize": 1000, - "chunkOverlap": 200, - "separator": "\\n", - "embeddingModel": "text-embedding-3-small", + "chunkSize": knowledge_config.chunk_size, + "chunkOverlap": knowledge_config.chunk_overlap, + "separator": "\\n", # Keep hardcoded for now as it's not in config + "embeddingModel": knowledge_config.embedding_model, } if flow_data.get("data", {}).get("nodes"): @@ -104,3 +129,105 @@ async def get_settings(request, session_manager): return JSONResponse( {"error": f"Failed to retrieve settings: {str(e)}"}, status_code=500 ) + + +async def update_settings(request, session_manager): + """Update application settings""" + try: + # Get current configuration + current_config = get_openrag_config() + + # Check if config is marked as edited + if not current_config.edited: + return JSONResponse( + {"error": "Configuration must be marked as edited before updates are allowed"}, + status_code=403 + ) + + # Parse request body + body = await request.json() + + # Validate allowed fields + allowed_fields = { + "llm_model", "system_prompt", "ocr", "picture_descriptions", + "chunk_size", "chunk_overlap" + } + + # Check for invalid fields + invalid_fields = set(body.keys()) - allowed_fields + if invalid_fields: + return JSONResponse( + {"error": f"Invalid fields: {', '.join(invalid_fields)}. Allowed fields: {', '.join(allowed_fields)}"}, + status_code=400 + ) + + # Update configuration + config_updated = False + + # Update agent settings + if "llm_model" in body: + current_config.agent.llm_model = body["llm_model"] + config_updated = True + + if "system_prompt" in body: + current_config.agent.system_prompt = body["system_prompt"] + config_updated = True + + # Update knowledge settings + if "ocr" in body: + if not isinstance(body["ocr"], bool): + return JSONResponse( + {"error": "ocr must be a boolean value"}, + status_code=400 + ) + current_config.knowledge.ocr = body["ocr"] + config_updated = True + + if "picture_descriptions" in body: + if not isinstance(body["picture_descriptions"], bool): + return JSONResponse( + {"error": "picture_descriptions must be a boolean value"}, + status_code=400 + ) + current_config.knowledge.picture_descriptions = body["picture_descriptions"] + config_updated = True + + if "chunk_size" in body: + if not isinstance(body["chunk_size"], int) or body["chunk_size"] <= 0: + return JSONResponse( + {"error": "chunk_size must be a positive integer"}, + status_code=400 + ) + current_config.knowledge.chunk_size = body["chunk_size"] + config_updated = True + + if "chunk_overlap" in body: + if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0: + return JSONResponse( + {"error": "chunk_overlap must be a non-negative integer"}, + status_code=400 + ) + current_config.knowledge.chunk_overlap = body["chunk_overlap"] + config_updated = True + + if not config_updated: + return JSONResponse( + {"error": "No valid fields provided for update"}, + status_code=400 + ) + + # Save the updated configuration + if config_manager.save_config_file(current_config): + logger.info("Configuration updated successfully", updated_fields=list(body.keys())) + return JSONResponse({"message": "Configuration updated successfully"}) + else: + return JSONResponse( + {"error": "Failed to save configuration"}, + status_code=500 + ) + + except Exception as e: + logger.error("Failed to update settings", error=str(e)) + return JSONResponse( + {"error": f"Failed to update settings: {str(e)}"}, status_code=500 + ) diff --git a/src/config/config_manager.py b/src/config/config_manager.py new file mode 100644 index 00000000..93e6a279 --- /dev/null +++ b/src/config/config_manager.py @@ -0,0 +1,197 @@ +"""Configuration management for OpenRAG.""" + +import os +import yaml +from pathlib import Path +from typing import Dict, Any, Optional +from dataclasses import dataclass, asdict +from utils.logging_config import get_logger + +logger = get_logger(__name__) + + +@dataclass +class ProviderConfig: + """Model provider configuration.""" + model_provider: str = "openai" # openai, anthropic, etc. + api_key: str = "" + + +@dataclass +class KnowledgeConfig: + """Knowledge/ingestion configuration.""" + embedding_model: str = "text-embedding-3-small" + chunk_size: int = 1000 + chunk_overlap: int = 200 + ocr: bool = True + picture_descriptions: bool = False + + +@dataclass +class AgentConfig: + """Agent configuration.""" + llm_model: str = "gpt-4o-mini" + system_prompt: str = "You are a helpful AI assistant with access to a knowledge base. Answer questions based on the provided context." + + +@dataclass +class OpenRAGConfig: + """Complete OpenRAG configuration.""" + provider: ProviderConfig + knowledge: KnowledgeConfig + agent: AgentConfig + edited: bool = False # Track if manually edited + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "OpenRAGConfig": + """Create config from dictionary.""" + return cls( + provider=ProviderConfig(**data.get("provider", {})), + knowledge=KnowledgeConfig(**data.get("knowledge", {})), + agent=AgentConfig(**data.get("agent", {})) + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert config to dictionary.""" + return asdict(self) + + +class ConfigManager: + """Manages OpenRAG configuration from multiple sources.""" + + def __init__(self, config_file: Optional[str] = None): + """Initialize configuration manager. + + Args: + config_file: Path to configuration file. Defaults to 'config.yaml' in project root. + """ + self.config_file = Path(config_file) if config_file else Path("config.yaml") + self._config: Optional[OpenRAGConfig] = None + + def load_config(self) -> OpenRAGConfig: + """Load configuration from environment variables and config file. + + Priority order: + 1. Environment variables (highest) + 2. Configuration file + 3. Defaults (lowest) + """ + if self._config is not None: + return self._config + + # Start with defaults + config_data = { + "provider": {}, + "knowledge": {}, + "agent": {} + } + + # Load from config file if it exists + if self.config_file.exists(): + try: + with open(self.config_file, 'r') as f: + file_config = yaml.safe_load(f) or {} + + # Merge file config + for section in ["provider", "knowledge", "agent"]: + if section in file_config: + config_data[section].update(file_config[section]) + + logger.info(f"Loaded configuration from {self.config_file}") + except Exception as e: + logger.warning(f"Failed to load config file {self.config_file}: {e}") + + # Create config object first to check edited flags + temp_config = OpenRAGConfig.from_dict(config_data) + + # Override with environment variables (highest priority, but respect edited flags) + self._load_env_overrides(config_data, temp_config) + + # Create config object + self._config = OpenRAGConfig.from_dict(config_data) + + logger.debug("Configuration loaded", config=self._config.to_dict()) + return self._config + + def _load_env_overrides(self, config_data: Dict[str, Any], temp_config: Optional["OpenRAGConfig"] = None) -> None: + """Load environment variable overrides, respecting edited flag.""" + + # Skip all environment overrides if config has been manually edited + if temp_config and temp_config.edited: + logger.debug("Skipping all env overrides - config marked as edited") + return + + # Provider settings + if os.getenv("MODEL_PROVIDER"): + config_data["provider"]["model_provider"] = os.getenv("MODEL_PROVIDER") + if os.getenv("PROVIDER_API_KEY"): + config_data["provider"]["api_key"] = os.getenv("PROVIDER_API_KEY") + # Backward compatibility for OpenAI + if os.getenv("OPENAI_API_KEY"): + config_data["provider"]["api_key"] = os.getenv("OPENAI_API_KEY") + if not config_data["provider"].get("model_provider"): + config_data["provider"]["model_provider"] = "openai" + + # Knowledge settings + if os.getenv("EMBEDDING_MODEL"): + config_data["knowledge"]["embedding_model"] = os.getenv("EMBEDDING_MODEL") + if os.getenv("CHUNK_SIZE"): + config_data["knowledge"]["chunk_size"] = int(os.getenv("CHUNK_SIZE")) + if os.getenv("CHUNK_OVERLAP"): + config_data["knowledge"]["chunk_overlap"] = int(os.getenv("CHUNK_OVERLAP")) + if os.getenv("OCR_ENABLED"): + config_data["knowledge"]["ocr"] = os.getenv("OCR_ENABLED").lower() in ("true", "1", "yes") + if os.getenv("PICTURE_DESCRIPTIONS_ENABLED"): + config_data["knowledge"]["picture_descriptions"] = os.getenv("PICTURE_DESCRIPTIONS_ENABLED").lower() in ("true", "1", "yes") + + # Agent settings + if os.getenv("LLM_MODEL"): + config_data["agent"]["llm_model"] = os.getenv("LLM_MODEL") + if os.getenv("SYSTEM_PROMPT"): + config_data["agent"]["system_prompt"] = os.getenv("SYSTEM_PROMPT") + + def get_config(self) -> OpenRAGConfig: + """Get current configuration, loading if necessary.""" + if self._config is None: + return self.load_config() + return self._config + + def reload_config(self) -> OpenRAGConfig: + """Force reload configuration from sources.""" + self._config = None + return self.load_config() + + def save_config_file(self, config: Optional[OpenRAGConfig] = None) -> bool: + """Save configuration to file. + + Args: + config: Configuration to save. If None, uses current config. + + Returns: + True if saved successfully, False otherwise. + """ + if config is None: + config = self.get_config() + + # Mark config as edited when saving + config.edited = True + + try: + # Ensure directory exists + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + with open(self.config_file, 'w') as f: + yaml.dump(config.to_dict(), f, default_flow_style=False, indent=2) + + # Update cached config to reflect the edited flags + self._config = config + + logger.info(f"Configuration saved to {self.config_file} - marked as edited") + return True + except Exception as e: + logger.error(f"Failed to save configuration to {self.config_file}: {e}") + return False + + +# Global config manager instance +config_manager = ConfigManager() diff --git a/src/config/settings.py b/src/config/settings.py index 715146fb..3e60b45c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -17,6 +17,9 @@ load_dotenv("../") logger = get_logger(__name__) +# Import configuration manager +from .config_manager import config_manager + # Environment variables OPENSEARCH_HOST = os.getenv("OPENSEARCH_HOST", "localhost") OPENSEARCH_PORT = int(os.getenv("OPENSEARCH_PORT", "9200")) @@ -398,3 +401,21 @@ class AppClients: # Global clients instance clients = AppClients() + +# Configuration access +def get_openrag_config(): + """Get current OpenRAG configuration.""" + return config_manager.get_config() + +# Expose configuration settings for backward compatibility and easy access +def get_provider_config(): + """Get provider configuration.""" + return get_openrag_config().provider + +def get_knowledge_config(): + """Get knowledge configuration.""" + return get_openrag_config().knowledge + +def get_agent_config(): + """Get agent configuration.""" + return get_openrag_config().agent diff --git a/src/main.py b/src/main.py index 1c0dc09f..32899ae9 100644 --- a/src/main.py +++ b/src/main.py @@ -890,7 +890,7 @@ async def create_app(): ), methods=["POST"], ), - # Settings endpoint + # Settings endpoints Route( "/settings", require_auth(services["session_manager"])( @@ -900,6 +900,15 @@ async def create_app(): ), methods=["GET"], ), + Route( + "/settings", + require_auth(services["session_manager"])( + partial( + settings.update_settings, session_manager=services["session_manager"] + ) + ), + methods=["POST"], + ), Route( "/nudges", require_auth(services["session_manager"])(