"""Environment-backed configuration for the Meeting Assistant application.""" from __future__ import annotations import json import os from dataclasses import dataclass from pathlib import Path class ConfigurationError(ValueError): """Raised when required processing configuration is unavailable.""" @dataclass(frozen=True) class AppSettings: """Machine-specific Meeting Lab settings kept outside the UI.""" data_root: Path whisper_model: Path | None whisper_executable: str = "whisper-cli" ffmpeg_executable: str = "ffmpeg" protocol_model: str = "qwen3.8:27B" ollama_endpoint: str = "http://127.0.0.1:11434" language: str = "de" threads: str | int = "auto" diarization_mode: str = "auto" diarization_runtime: str = "native" diarization_container_image: str | None = None diarization_container_args: tuple[str, ...] = () @classmethod def from_environment(cls) -> AppSettings: """Load settings from MKA_* environment variables.""" whisper_model = os.getenv("MKA_WHISPER_MODEL") threads = os.getenv("MKA_WHISPER_THREADS", "auto") parsed_threads: str | int = int(threads) if threads.isdigit() else threads return cls( data_root=Path(os.getenv("MKA_DATA_ROOT", "data/meetings")), whisper_model=Path(whisper_model) if whisper_model else None, whisper_executable=os.getenv("MKA_WHISPER_EXECUTABLE", "whisper-cli"), ffmpeg_executable=os.getenv("MKA_FFMPEG_EXECUTABLE", "ffmpeg"), protocol_model=os.getenv("MKA_PROTOCOL_MODEL", "qwen3.8:27B"), ollama_endpoint=os.getenv("MKA_OLLAMA_ENDPOINT", "http://127.0.0.1:11434"), language=os.getenv("MKA_LANGUAGE", "de"), threads=parsed_threads, diarization_mode=os.getenv("MKA_DIARIZATION_MODE", "auto"), diarization_runtime=os.getenv("MKA_DIARIZATION_RUNTIME", "native"), diarization_container_image=os.getenv("MKA_DIARIZATION_CONTAINER_IMAGE"), diarization_container_args=_parse_container_args( os.getenv("MKA_DIARIZATION_CONTAINER_ARGS") ), ) def validate_for_processing(self) -> None: """Validate settings needed before a Meeting Lab run starts.""" if self.whisper_model is None: raise ConfigurationError("MKA_WHISPER_MODEL is not configured.") if not self.whisper_model.is_file(): raise ConfigurationError( f"Configured Whisper model does not exist: {self.whisper_model}" ) if not self.whisper_executable.strip(): raise ConfigurationError("MKA_WHISPER_EXECUTABLE must not be empty.") if not self.ffmpeg_executable.strip(): raise ConfigurationError("MKA_FFMPEG_EXECUTABLE must not be empty.") if self.diarization_mode not in {"auto", "cpu", "gpu"}: raise ConfigurationError("MKA_DIARIZATION_MODE must be one of: auto, cpu, gpu.") if self.diarization_runtime not in {"native", "container"}: raise ConfigurationError("MKA_DIARIZATION_RUNTIME must be native or container.") if self.diarization_runtime == "container" and not self.diarization_container_image: raise ConfigurationError( "MKA_DIARIZATION_CONTAINER_IMAGE is required for container runtime." ) if any( not isinstance(argument, str) or not argument for argument in self.diarization_container_args ): raise ConfigurationError( "MKA_DIARIZATION_CONTAINER_ARGS must contain only non-empty strings." ) def _parse_container_args(value: str | None) -> tuple[str, ...]: """Parse an ordered JSON array of opaque container command arguments.""" if value is None or not value.strip(): return () try: parsed = json.loads(value) except json.JSONDecodeError as exc: raise ConfigurationError( "MKA_DIARIZATION_CONTAINER_ARGS must be a JSON array of strings." ) from exc if not isinstance(parsed, list) or any( not isinstance(argument, str) or not argument for argument in parsed ): raise ConfigurationError( "MKA_DIARIZATION_CONTAINER_ARGS must be a JSON array of non-empty strings." ) return tuple(parsed)