""" Flare – ConfigProvider (with Provider Abstraction and Multi-language Support) """ from __future__ import annotations import json, os, threading from pathlib import Path from typing import Any, Dict, List, Optional from datetime import datetime import commentjson from pydantic import BaseModel, Field, HttpUrl, ValidationError from utils import log from encryption_utils import decrypt # ===================== New Provider Classes ===================== class ProviderConfig(BaseModel): """Provider definition with requirements""" type: str = Field(..., pattern=r"^(llm|tts|stt)$") name: str display_name: str requires_endpoint: bool = False requires_api_key: bool = True requires_repo_info: bool = False description: Optional[str] = None class ProviderSettings(BaseModel): """Runtime provider settings""" name: str api_key: Optional[str] = None endpoint: Optional[str] = None settings: Dict[str, Any] = Field(default_factory=dict) class LocalizedCaption(BaseModel): """Multi-language caption support""" locale_code: str caption: str class LocalizedExample(BaseModel): """Multi-language example support""" locale_code: str example: str # ===================== Global Configuration ===================== class GlobalConfig(BaseModel): # Provider settings (replaces work_mode, cloud_token, spark_endpoint) llm_provider: ProviderSettings tts_provider: ProviderSettings = ProviderSettings(name="no_tts") stt_provider: ProviderSettings = ProviderSettings(name="no_stt") # Available providers providers: List[ProviderConfig] = [] # User management users: List["UserConfig"] = [] # Helper methods for providers def get_provider_config(self, provider_type: str, provider_name: str) -> Optional[ProviderConfig]: """Get provider configuration by type and name""" return next( (p for p in self.providers if p.type == provider_type and p.name == provider_name), None ) def get_providers_by_type(self, provider_type: str) -> List[ProviderConfig]: """Get all providers of a specific type""" return [p for p in self.providers if p.type == provider_type] def get_plain_api_key(self, provider_type: str) -> Optional[str]: """Get decrypted API key for a provider type""" provider_map = { "llm": self.llm_provider, "tts": self.tts_provider, "stt": self.stt_provider } provider = provider_map.get(provider_type) if provider and provider.api_key: return decrypt(provider.api_key) if provider.api_key else None return None # Backward compatibility helpers def is_cloud_mode(self) -> bool: """Check if running in cloud mode (HuggingFace)""" return bool(os.environ.get("SPACE_ID")) def is_gpt_mode(self) -> bool: """Check if using GPT provider""" return self.llm_provider.name.startswith("gpt4o") # ===================== Other Config Classes ===================== class UserConfig(BaseModel): username: str password_hash: str salt: str class RetryConfig(BaseModel): retry_count: int = Field(3, alias="max_attempts") backoff_seconds: int = 2 strategy: str = Field("static", pattern=r"^(static|exponential)$") class ProxyConfig(BaseModel): enabled: bool = True url: HttpUrl class APIAuthConfig(BaseModel): enabled: bool = False token_endpoint: Optional[HttpUrl] = None response_token_path: str = "access_token" token_request_body: Dict[str, Any] = Field({}, alias="body_template") token_refresh_endpoint: Optional[HttpUrl] = None token_refresh_body: Dict[str, Any] = {} class Config: extra = "allow" populate_by_name = True class APIConfig(BaseModel): name: str url: HttpUrl method: str = Field("GET", pattern=r"^(GET|POST|PUT|PATCH|DELETE)$") headers: Dict[str, Any] = {} body_template: Dict[str, Any] = {} timeout_seconds: int = 10 retry: RetryConfig = RetryConfig() proxy: Optional[str | ProxyConfig] = None auth: Optional[APIAuthConfig] = None response_prompt: Optional[str] = None response_mappings: List[Dict[str, Any]] = [] deleted: bool = False last_update_date: Optional[str] = None last_update_user: Optional[str] = None created_date: Optional[str] = None created_by: Optional[str] = None class Config: extra = "allow" populate_by_name = True # ===================== Intent / Parameter ===================== class ParameterConfig(BaseModel): name: str caption: List[LocalizedCaption] = [] # Multi-language captions type: str = Field(..., pattern=r"^(int|float|bool|str|string|date)$") required: bool = True variable_name: str extraction_prompt: Optional[str] = None validation_regex: Optional[str] = None invalid_prompt: Optional[str] = None type_error_prompt: Optional[str] = None def canonical_type(self) -> str: if self.type == "string": return "str" elif self.type == "date": return "str" # Store dates as strings in ISO format return self.type def get_caption_for_locale(self, locale_code: str, default_locale: str = "tr") -> str: """Get caption for specific locale with fallback""" # Try exact match caption = next((c.caption for c in self.caption if c.locale_code == locale_code), None) if caption: return caption # Try language code only (e.g., "tr" from "tr-TR") lang_code = locale_code.split("-")[0] caption = next((c.caption for c in self.caption if c.locale_code.startswith(lang_code)), None) if caption: return caption # Try default locale caption = next((c.caption for c in self.caption if c.locale_code.startswith(default_locale)), None) if caption: return caption # Return first available or name return self.caption[0].caption if self.caption else self.name class IntentConfig(BaseModel): name: str caption: Optional[str] = "" # Removed locale field - will use project's locale settings dependencies: List[str] = [] examples: List[LocalizedExample] = [] # Multi-language examples detection_prompt: Optional[str] = None parameters: List[ParameterConfig] = [] action: str fallback_timeout_prompt: Optional[str] = None fallback_error_prompt: Optional[str] = None class Config: extra = "allow" def get_examples_for_locale(self, locale_code: str) -> List[str]: """Get examples for specific locale""" # Try exact match examples = [e.example for e in self.examples if e.locale_code == locale_code] if examples: return examples # Try language code only lang_code = locale_code.split("-")[0] examples = [e.example for e in self.examples if e.locale_code.startswith(lang_code)] if examples: return examples # Return all examples if no locale match return [e.example for e in self.examples] # ===================== Version / Project ===================== class LLMConfig(BaseModel): repo_id: str generation_config: Dict[str, Any] = {} use_fine_tune: bool = False fine_tune_zip: str = "" class VersionConfig(BaseModel): id: int = Field(..., alias="version_number") no: Optional[int] = None caption: Optional[str] = "" description: Optional[str] = "" published: bool = False deleted: bool = False created_date: Optional[str] = None created_by: Optional[str] = None last_update_date: Optional[str] = None last_update_user: Optional[str] = None publish_date: Optional[str] = None published_by: Optional[str] = None general_prompt: str llm: LLMConfig intents: List[IntentConfig] class Config: extra = "allow" populate_by_name = True class ProjectConfig(BaseModel): id: Optional[int] = None name: str caption: Optional[str] = "" icon: Optional[str] = "folder" description: Optional[str] = "" enabled: bool = True last_version_number: Optional[int] = None version_id_counter: int = 1 versions: List[VersionConfig] # Language settings - changed from default_language/supported_languages default_locale: str = "tr" supported_locales: List[str] = ["tr"] timezone: Optional[str] = "Europe/Istanbul" region: Optional[str] = "tr-TR" deleted: bool = False created_date: Optional[str] = None created_by: Optional[str] = None last_update_date: Optional[str] = None last_update_user: Optional[str] = None class Config: extra = "allow" # ===================== Activity Log ===================== class ActivityLogEntry(BaseModel): timestamp: str username: str action: str entity_type: str entity_id: Optional[int] = None entity_name: Optional[str] = None details: Optional[str] = None # ===================== Service Config ===================== class ServiceConfig(BaseModel): global_config: GlobalConfig = Field(..., alias="config") projects: List[ProjectConfig] apis: List[APIConfig] activity_log: List[ActivityLogEntry] = [] # Config level fields project_id_counter: int = 1 last_update_date: Optional[str] = None last_update_user: Optional[str] = None # runtime helpers (skip validation) _api_by_name: Dict[str, APIConfig] = {} def build_index(self): self._api_by_name = {a.name: a for a in self.apis} def get_api(self, name: str) -> Optional[APIConfig]: return self._api_by_name.get(name) def to_jsonc_dict(self) -> dict: """Convert to dict for saving to JSONC file""" data = self.model_dump(by_alias=True, exclude={'_api_by_name'}) # Convert API configs for api in data.get('apis', []): # Convert headers and body_template to JSON strings if needed if 'headers' in api and isinstance(api['headers'], dict) and api['headers']: api['headers'] = json.dumps(api['headers'], ensure_ascii=False) if 'body_template' in api and isinstance(api['body_template'], dict) and api['body_template']: api['body_template'] = json.dumps(api['body_template'], ensure_ascii=False) # Convert auth configs if 'auth' in api and api['auth']: if 'token_request_body' in api['auth'] and isinstance(api['auth']['token_request_body'], dict): api['auth']['body_template'] = api['auth']['token_request_body'] del api['auth']['token_request_body'] if 'token_refresh_body' in api['auth'] and isinstance(api['auth']['token_refresh_body'], dict): api['auth']['token_refresh_body'] = json.dumps(api['auth']['token_refresh_body'], ensure_ascii=False) return data def save(self): """Save configuration to file""" config_path = Path(__file__).parent / "service_config.jsonc" data = self.to_jsonc_dict() # Pretty print with indentation json_str = json.dumps(data, ensure_ascii=False, indent=2) with open(config_path, 'w', encoding='utf-8') as f: f.write(json_str) log("✅ Configuration saved to service_config.jsonc") # ===================== Provider Singleton ===================== class ConfigProvider: _instance: Optional[ServiceConfig] = None _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc" _lock = threading.Lock() _environment_checked = False @classmethod def get(cls) -> ServiceConfig: """Get cached config - thread-safe""" if cls._instance is None: with cls._lock: # Double-checked locking pattern if cls._instance is None: cls._instance = cls._load() cls._instance.build_index() # Environment kontrolünü sadece ilk yüklemede yap if not cls._environment_checked: cls._check_environment_setup() cls._environment_checked = True return cls._instance @classmethod def reload(cls) -> ServiceConfig: """Force reload configuration from file - used after UI saves""" with cls._lock: log("🔄 Reloading configuration...") cls._instance = None return cls.get() @classmethod def _load(cls) -> ServiceConfig: """Load configuration from service_config.jsonc""" try: log(f"📂 Loading config from: {cls._CONFIG_PATH}") if not cls._CONFIG_PATH.exists(): raise FileNotFoundError(f"Config file not found: {cls._CONFIG_PATH}") with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f: config_data = commentjson.load(f) # Ensure required fields exist in config data if 'config' not in config_data: config_data['config'] = {} # Parse API configs specially if 'apis' in config_data: for api in config_data['apis']: # Parse JSON string fields if 'headers' in api and isinstance(api['headers'], str): try: api['headers'] = json.loads(api['headers']) except: api['headers'] = {} if 'body_template' in api and isinstance(api['body_template'], str): try: api['body_template'] = json.loads(api['body_template']) except: api['body_template'] = {} # Parse auth configs if 'auth' in api and api['auth']: if 'body_template' in api['auth'] and isinstance(api['auth']['body_template'], str): try: api['auth']['token_request_body'] = json.loads(api['auth']['body_template']) except: api['auth']['token_request_body'] = {} # Load and validate cfg = ServiceConfig.model_validate(config_data) log("✅ Configuration loaded successfully") return cfg except Exception as e: log(f"❌ Error loading config: {e}") raise @classmethod def _check_environment_setup(cls): """Check if environment is properly configured""" if not cls._instance: return cfg = cls._instance.global_config # Check LLM provider if not cfg.llm_provider or not cfg.llm_provider.name: log("⚠️ WARNING: No LLM provider configured") return provider_config = cfg.get_provider_config("llm", cfg.llm_provider.name) if not provider_config: log(f"⚠️ WARNING: Unknown LLM provider: {cfg.llm_provider.name}") return # Check requirements if provider_config.requires_api_key and not cfg.llm_provider.api_key: log(f"⚠️ WARNING: {provider_config.display_name} requires API key but none configured") if provider_config.requires_endpoint and not cfg.llm_provider.endpoint: log(f"⚠️ WARNING: {provider_config.display_name} requires endpoint but none configured") log(f"✅ LLM Provider: {provider_config.display_name}") # ===================== CRUD Operations ===================== @classmethod def add_activity_log(cls, username: str, action: str, entity_type: str, entity_id: Optional[int] = None, entity_name: Optional[str] = None, details: Optional[str] = None): """Add activity log entry""" if cls._instance is None: cls.get() entry = ActivityLogEntry( timestamp=datetime.now().isoformat() + "Z", username=username, action=action, entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, details=details ) cls._instance.activity_log.append(entry) # Keep only last 1000 entries if len(cls._instance.activity_log) > 1000: cls._instance.activity_log = cls._instance.activity_log[-1000:] @classmethod def update_environment(cls, update_data: dict, username: str) -> None: """Update environment configuration""" if cls._instance is None: cls.get() config = cls._instance.global_config # Update provider settings if 'llm_provider' in update_data: llm_data = update_data['llm_provider'] if 'api_key' in llm_data and llm_data['api_key'] and not llm_data['api_key'].startswith('enc:'): from encryption_utils import encrypt llm_data['api_key'] = encrypt(llm_data['api_key']) config.llm_provider = ProviderSettings(**llm_data) if 'tts_provider' in update_data: tts_data = update_data['tts_provider'] if 'api_key' in tts_data and tts_data['api_key'] and not tts_data['api_key'].startswith('enc:'): from encryption_utils import encrypt tts_data['api_key'] = encrypt(tts_data['api_key']) config.tts_provider = ProviderSettings(**tts_data) if 'stt_provider' in update_data: stt_data = update_data['stt_provider'] if 'api_key' in stt_data and stt_data['api_key'] and not stt_data['api_key'].startswith('enc:'): from encryption_utils import encrypt stt_data['api_key'] = encrypt(stt_data['api_key']) config.stt_provider = ProviderSettings(**stt_data) # Update metadata cls._instance.last_update_date = datetime.now().isoformat() + "Z" cls._instance.last_update_user = username # Add activity log cls.add_activity_log(username, "UPDATE_ENVIRONMENT", "environment", None, None, f"Updated providers: LLM={config.llm_provider.name}, TTS={config.tts_provider.name}, STT={config.stt_provider.name}") # Save cls._instance.save() @classmethod def get_project(cls, project_id: int) -> Optional[ProjectConfig]: """Get project by ID""" if cls._instance is None: cls.get() return next((p for p in cls._instance.projects if p.id == project_id), None) @classmethod def create_project(cls, project_data: dict, username: str) -> ProjectConfig: """Create new project with initial version""" if cls._instance is None: cls.get() # Check name uniqueness if any(p.name == project_data['name'] for p in cls._instance.projects if not p.deleted): raise ValueError(f"Project name '{project_data['name']}' already exists") # Create project new_project = ProjectConfig( id=cls._instance.project_id_counter, name=project_data['name'], caption=project_data.get('caption', ''), icon=project_data.get('icon', 'folder'), description=project_data.get('description', ''), enabled=True, default_locale=project_data.get('default_locale', 'tr'), supported_locales=project_data.get('supported_locales', ['tr']), timezone=project_data.get('timezone', 'Europe/Istanbul'), region=project_data.get('region', 'tr-TR'), version_id_counter=1, versions=[], deleted=False, created_date=datetime.now().isoformat() + "Z", created_by=username, last_update_date=datetime.now().isoformat() + "Z", last_update_user=username ) # Create initial version initial_version = VersionConfig( id=1, version_number=1, no=1, caption="Version 1", description="Initial version", published=False, deleted=False, created_date=datetime.now().isoformat() + "Z", created_by=username, last_update_date=datetime.now().isoformat() + "Z", last_update_user=username, general_prompt="You are a helpful assistant.", llm=LLMConfig( repo_id="", generation_config={ "max_new_tokens": 512, "temperature": 0.7, "top_p": 0.95 } ), intents=[] ) new_project.versions.append(initial_version) new_project.last_version_number = 1 # Add to config cls._instance.projects.append(new_project) cls._instance.project_id_counter += 1 # Add activity log cls.add_activity_log(username, "CREATE_PROJECT", "project", new_project.id, new_project.name) # Save cls._instance.save() return new_project @classmethod def update_project(cls, project_id: int, update_data: dict, username: str) -> ProjectConfig: """Update project""" if cls._instance is None: cls.get() project = cls.get_project(project_id) if not project: raise ValueError(f"Project not found: {project_id}") if project.deleted: raise ValueError("Cannot update deleted project") # Update fields if 'caption' in update_data: project.caption = update_data['caption'] if 'icon' in update_data: project.icon = update_data['icon'] if 'description' in update_data: project.description = update_data['description'] if 'default_locale' in update_data: project.default_locale = update_data['default_locale'] if 'supported_locales' in update_data: project.supported_locales = update_data['supported_locales'] if 'timezone' in update_data: project.timezone = update_data['timezone'] if 'region' in update_data: project.region = update_data['region'] # Update metadata project.last_update_date = datetime.now().isoformat() + "Z" project.last_update_user = username # Add activity log cls.add_activity_log(username, "UPDATE_PROJECT", "project", project.id, project.name) # Save cls._instance.save() return project @classmethod def delete_project(cls, project_id: int, username: str) -> None: """Soft delete project""" if cls._instance is None: cls.get() project = cls.get_project(project_id) if not project: raise ValueError(f"Project not found: {project_id}") project.deleted = True project.last_update_date = datetime.now().isoformat() + "Z" project.last_update_user = username # Add activity log cls.add_activity_log(username, "DELETE_PROJECT", "project", project.id, project.name) # Save cls._instance.save() @classmethod def toggle_project(cls, project_id: int, username: str) -> bool: """Toggle project enabled status""" if cls._instance is None: cls.get() project = cls.get_project(project_id) if not project: raise ValueError(f"Project not found: {project_id}") project.enabled = not project.enabled project.last_update_date = datetime.now().isoformat() + "Z" project.last_update_user = username # Add activity log action = "ENABLE_PROJECT" if project.enabled else "DISABLE_PROJECT" cls.add_activity_log(username, action, "project", project.id, project.name) # Save cls._instance.save() return project.enabled @classmethod def create_api(cls, api_data: dict, username: str) -> APIConfig: """Create new API""" if cls._instance is None: cls.get() # Check name uniqueness if any(a.name == api_data['name'] for a in cls._instance.apis if not a.deleted): raise ValueError(f"API name '{api_data['name']}' already exists") # Create API new_api = APIConfig( name=api_data['name'], url=api_data['url'], method=api_data.get('method', 'GET'), headers=api_data.get('headers', {}), body_template=api_data.get('body_template', {}), timeout_seconds=api_data.get('timeout_seconds', 10), retry=RetryConfig(**api_data.get('retry', {})) if 'retry' in api_data else RetryConfig(), proxy=api_data.get('proxy'), auth=APIAuthConfig(**api_data.get('auth', {})) if api_data.get('auth') else None, response_prompt=api_data.get('response_prompt'), response_mappings=api_data.get('response_mappings', []), deleted=False, created_date=datetime.now().isoformat() + "Z", created_by=username, last_update_date=datetime.now().isoformat() + "Z", last_update_user=username ) # Add to config cls._instance.apis.append(new_api) # Rebuild index cls._instance.build_index() # Add activity log cls.add_activity_log(username, "CREATE_API", "api", None, new_api.name) # Save cls._instance.save() return new_api @classmethod def update_api(cls, api_name: str, update_data: dict, username: str) -> APIConfig: """Update API""" if cls._instance is None: cls.get() api = next((a for a in cls._instance.apis if a.name == api_name and not a.deleted), None) if not api: raise ValueError(f"API not found: {api_name}") # Update fields for field in ['url', 'method', 'headers', 'body_template', 'timeout_seconds', 'proxy', 'response_prompt', 'response_mappings']: if field in update_data: setattr(api, field, update_data[field]) # Update retry config if 'retry' in update_data: api.retry = RetryConfig(**update_data['retry']) # Update auth config if 'auth' in update_data: if update_data['auth']: api.auth = APIAuthConfig(**update_data['auth']) else: api.auth = None # Update metadata api.last_update_date = datetime.now().isoformat() + "Z" api.last_update_user = username # Add activity log cls.add_activity_log(username, "UPDATE_API", "api", None, api.name) # Save cls._instance.save() return api @classmethod def delete_api(cls, api_name: str, username: str) -> None: """Soft delete API""" if cls._instance is None: cls.get() api = next((a for a in cls._instance.apis if a.name == api_name and not a.deleted), None) if not api: raise ValueError(f"API not found: {api_name}") # Check if API is used in any intent for project in cls._instance.projects: if getattr(project, 'deleted', False): continue for version in project.versions: if getattr(version, 'deleted', False): continue for intent in version.intents: if intent.action == api_name: raise ValueError(f"API is used in intent '{intent.name}' in project '{project.name}' version {version.no}") api.deleted = True api.last_update_date = datetime.now().isoformat() + "Z" api.last_update_user = username # Add activity log cls.add_activity_log(username, "DELETE_API", "api", None, api_name) # Save cls._instance.save() @classmethod def import_project(cls, project_data: dict, username: str) -> ProjectConfig: """Import project from JSON""" if cls._instance is None: cls.get() # Validate structure if "name" not in project_data: raise ValueError("Invalid project data") # Create new project with imported data imported_data = { "name": project_data["name"], "caption": project_data.get("caption", ""), "icon": project_data.get("icon", "folder"), "description": project_data.get("description", ""), "default_locale": project_data.get("default_locale", "tr"), "supported_locales": project_data.get("supported_locales", ["tr"]) } # Create project new_project = cls.create_project(imported_data, username) # Clear default version new_project.versions = [] # Import versions for idx, version_data in enumerate(project_data.get("versions", [])): new_version = VersionConfig( id=idx + 1, version_number=idx + 1, no=idx + 1, caption=version_data.get("caption", f"Version {idx + 1}"), description=version_data.get("description", ""), published=False, deleted=False, created_date=datetime.now().isoformat() + "Z", created_by=username, last_update_date=datetime.now().isoformat() + "Z", last_update_user=username, general_prompt=version_data.get("general_prompt", ""), llm=LLMConfig(**version_data.get("llm", {})) if version_data.get("llm") else LLMConfig( repo_id="", generation_config={ "max_new_tokens": 512, "temperature": 0.7, "top_p": 0.95 } ), intents=[] ) # Import intents for intent_data in version_data.get("intents", []): intent = IntentConfig( name=intent_data.get("name", ""), caption=intent_data.get("caption", ""), detection_prompt=intent_data.get("detection_prompt", ""), action=intent_data.get("action", ""), fallback_timeout_prompt=intent_data.get("fallback_timeout_prompt"), fallback_error_prompt=intent_data.get("fallback_error_prompt"), examples=[], parameters=[] ) # Convert examples if "examples" in intent_data: if isinstance(intent_data["examples"], list): for example in intent_data["examples"]: if isinstance(example, str): # Old format - use project default locale intent.examples.append(LocalizedExample( locale_code=new_project.default_locale, example=example )) elif isinstance(example, dict): # New format intent.examples.append(LocalizedExample(**example)) # Convert parameters for param_data in intent_data.get("parameters", []): param = ParameterConfig( name=param_data.get("name", ""), type=param_data.get("type", "str"), required=param_data.get("required", True), variable_name=param_data.get("variable_name", param_data.get("name", "")), extraction_prompt=param_data.get("extraction_prompt"), validation_regex=param_data.get("validation_regex"), invalid_prompt=param_data.get("invalid_prompt"), type_error_prompt=param_data.get("type_error_prompt"), caption=[] ) # Convert caption if "caption" in param_data: if isinstance(param_data["caption"], str): # Old format param.caption.append(LocalizedCaption( locale_code=new_project.default_locale, caption=param_data["caption"] )) elif isinstance(param_data["caption"], list): # New format for cap in param_data["caption"]: param.caption.append(LocalizedCaption(**cap)) intent.parameters.append(param) new_version.intents.append(intent) new_project.versions.append(new_version) new_project.last_version_number = new_version.id # Update project counter if needed new_project.version_id_counter = len(new_project.versions) + 1 # Save cls._instance.save() # Add activity log cls.add_activity_log(username, "IMPORT_PROJECT", "project", new_project.id, new_project.name, f"Imported with {len(new_project.versions)} versions") return new_project