Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backends/advanced/docker-compose-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ services:
- ./data/test_audio_chunks:/app/audio_chunks
- ./data/test_debug_dir:/app/debug_dir
- ./data/test_data:/app/data
- ${CONFIG_FILE:-../../config/config.yml}:/app/config.yml # Mount config.yml for model registry and memory settings (writable for admin config updates)
- ../../config:/app/config # Mount entire config directory (contains config.yml, defaults.yml, templates)
environment:
# Same environment as backend
- MONGODB_URI=mongodb://mongo-test:27017/test_db
Expand Down
3 changes: 2 additions & 1 deletion backends/advanced/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ services:
- ./data/audio_chunks:/app/audio_chunks
- ./data/debug_dir:/app/debug_dir
- ./data:/app/data
- ../../config/config.yml:/app/config.yml # Removed :ro to allow UI config saving
- ../../config:/app/config # Mount entire config directory (contains config.yml, defaults.yml, templates)
environment:
- DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY}
- PARAKEET_ASR_URL=${PARAKEET_ASR_URL}
Expand Down Expand Up @@ -61,6 +61,7 @@ services:
- ./data/audio_chunks:/app/audio_chunks
- ./data:/app/data
- ../../config/config.yml:/app/config.yml # Removed :ro for consistency
- ../../config/defaults.yml:/app/defaults.yml:ro # Built-in defaults
environment:
- DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY}
- PARAKEET_ASR_URL=${PARAKEET_ASR_URL}
Expand Down
246 changes: 243 additions & 3 deletions backends/advanced/src/advanced_omi_backend/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""
Configuration management for Chronicle backend.

Currently contains diarization settings because they were used in multiple places
causing circular imports. Other configurations can be moved here as needed.
Provides central configuration loading with defaults.yml + config.yml merging.
Also contains diarization and speech detection settings.

Priority: config.yml > environment variables > defaults.yml
"""

import json
import logging
import os
import re
import shutil
import yaml
from pathlib import Path
from typing import Any, Dict, Optional

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -165,4 +170,239 @@ def get_audio_storage_settings():


# Initialize settings on module load
_diarization_settings = load_diarization_settings_from_file()
_diarization_settings = load_diarization_settings_from_file()


# ==============================================================================
# General Configuration Loading (config.yml + defaults.yml)
# ==============================================================================

# Cache for merged configuration
_CONFIG_CACHE: Optional[Dict[str, Any]] = None


def _resolve_env(value: Any) -> Any:
"""Resolve ``${VAR:-default}`` patterns inside a single value.

This helper is intentionally minimal: it only operates on strings and leaves
all other types unchanged. Patterns of the form ``${VAR}`` or
``${VAR:-default}`` are expanded using ``os.getenv``:

- If the environment variable **VAR** is set, its value is used.
- Otherwise the optional ``default`` is used (or ``""`` if omitted).

Examples:
>>> os.environ.get("OLLAMA_MODEL")
>>> _resolve_env("${OLLAMA_MODEL:-llama3.1:latest}")
'llama3.1:latest'

>>> os.environ["OLLAMA_MODEL"] = "llama3.2:latest"
>>> _resolve_env("${OLLAMA_MODEL:-llama3.1:latest}")
'llama3.2:latest'

>>> _resolve_env("Bearer ${OPENAI_API_KEY:-}")
'Bearer ' # when OPENAI_API_KEY is not set
"""
if not isinstance(value, str):
return value

pattern = re.compile(r"\$\{([^}:]+)(?::-(.*?))?\}")

def repl(match: re.Match[str]) -> str:
var, default = match.group(1), match.group(2)
return os.getenv(var, default or "")

return pattern.sub(repl, value)


def _deep_resolve_env(data: Any) -> Any:
"""Recursively resolve environment variables in nested structures.

This walks arbitrary Python structures produced by ``yaml.safe_load`` and
applies :func:`_resolve_env` to every string it finds. Dictionaries and
lists are traversed deeply; scalars are passed through unchanged.

Examples:
>>> os.environ["OPENAI_MODEL"] = "gpt-4o-mini"
>>> cfg = {
... "models": [
... {"model_name": "${OPENAI_MODEL:-gpt-4o-mini}"},
... {"model_url": "${OPENAI_BASE_URL:-https://api.openai.com/v1}"}
... ]
... }
>>> resolved = _deep_resolve_env(cfg)
>>> resolved["models"][0]["model_name"]
'gpt-4o-mini'
>>> resolved["models"][1]["model_url"]
'https://api.openai.com/v1'
"""
if isinstance(data, dict):
return {k: _deep_resolve_env(v) for k, v in data.items()}
if isinstance(data, list):
return [_deep_resolve_env(v) for v in data]
return _resolve_env(data)


def _deep_merge(base: dict, override: dict) -> dict:
"""Deep merge two dictionaries, with override taking precedence.

Args:
base: Base dictionary (defaults)
override: Override dictionary (from config.yml)

Returns:
Merged dictionary
"""
result = base.copy()
try:
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
except ValueError as e:
logger.error(f"Error in _deep_merge: {e}, base type: {type(base)}, override type: {type(override)}")
raise
return result


def _find_config_path() -> Path:
"""Find config.yml in expected locations.

Search order:
1. CONFIG_FILE environment variable
2. /app/config/config.yml (Docker container with config directory mount)
3. Current working directory
4. Walk up from module directory

Returns:
Path to config.yml (may not exist)
"""
# ENV override
cfg_env = os.getenv("CONFIG_FILE")
if cfg_env and Path(cfg_env).exists():
return Path(cfg_env)

# Common locations (container with config dir mount vs repo root)
candidates = [Path("/app/config/config.yml"), Path("config.yml")]

# Also walk up from current file's parents defensively
try:
for parent in Path(__file__).resolve().parents:
c = parent / "config.yml"
if c.exists():
return c
except Exception:
pass

for c in candidates:
if c.exists():
return c

# Last resort: return /app/config/config.yml path (may not exist yet)
return Path("/app/config/config.yml")


def get_config(force_reload: bool = False) -> Dict[str, Any]:
"""Get the full merged configuration (defaults.yml + config.yml + env vars).

This is the central function for loading configuration. It merges:
1. defaults.yml (fallback defaults)
2. config.yml (user overrides)
3. Environment variable resolution (${VAR:-default})

Priority: config.yml > environment variables > defaults.yml

Args:
force_reload: If True, reload from disk even if already cached

Returns:
Complete merged configuration dictionary with all sections

Example:
>>> config = get_config()
>>> memory_config = config.get("memory", {})
>>> chat_config = config.get("chat", {})
>>> models = config.get("models", [])
"""
global _CONFIG_CACHE

if _CONFIG_CACHE is not None and not force_reload:
return _CONFIG_CACHE

# Find config.yml path
cfg_path = _find_config_path()

# Load defaults.yml from same directory as config.yml
defaults_path = cfg_path.parent / "defaults.yml"
if defaults_path.exists():
try:
with defaults_path.open("r") as f:
raw = yaml.safe_load(f) or {}
logger.info(f"Loaded defaults from {defaults_path}")
except Exception as e:
logger.error(f"Failed to load defaults from {defaults_path}: {e}")
raw = {}
else:
logger.warning(f"No defaults.yml found at {defaults_path}, starting with empty config")
raw = {}

# Try to load config.yml and merge with defaults
if cfg_path.exists():
try:
with cfg_path.open("r") as f:
user_config = yaml.safe_load(f) or {}

# Merge user config over defaults (config.yml takes precedence)
raw = _deep_merge(raw, user_config)
logger.info(f"Loaded config from {cfg_path} (merged with defaults)")
except Exception as e:
logger.warning(f"Failed to load {cfg_path}, using defaults only: {e}")
else:
logger.info(f"No config.yml found at {cfg_path}, using defaults only")

# Resolve environment variables
raw = _deep_resolve_env(raw)

# Cache the result
_CONFIG_CACHE = raw

return raw


def reload_config() -> Dict[str, Any]:
"""Force reload configuration from disk.

This is useful after configuration files have been modified.

Returns:
Complete merged configuration dictionary
"""
return get_config(force_reload=True)


def get_config_section(section: str, default: Any = None) -> Any:
"""Get a specific section from the merged configuration.

Args:
section: Section name (e.g., "memory", "chat", "models")
default: Default value if section doesn't exist

Returns:
Configuration section or default value

Example:
>>> memory_config = get_config_section("memory", {})
>>> models = get_config_section("models", [])
"""
config = get_config()
return config.get(section, default)


def get_config_path() -> Path:
"""Get the path to config.yml being used.

Returns:
Path to config.yml
"""
return _find_config_path()
Loading
Loading