diff --git a/backend/igny8_core/ai/functions/workflow_functions/__init__.py b/backend/igny8_core/ai/functions/workflow_functions/__init__.py deleted file mode 100644 index 981b0267..00000000 --- a/backend/igny8_core/ai/functions/workflow_functions/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Workflow Functions -New AI functions using the unified template pattern. -""" - diff --git a/backend/igny8_core/ai/helpers/ai_core.py b/backend/igny8_core/ai/helpers/ai_core.py deleted file mode 100644 index 71488934..00000000 --- a/backend/igny8_core/ai/helpers/ai_core.py +++ /dev/null @@ -1,755 +0,0 @@ -""" -AI Core - Centralized execution and logging layer for all AI requests -Handles API calls, model selection, response parsing, and console logging -""" -import logging -import json -import re -import requests -import time -from typing import Dict, Any, Optional, List -from django.conf import settings - -from igny8_core.ai.constants import ( - DEFAULT_AI_MODEL, - JSON_MODE_MODELS, - MODEL_RATES, - IMAGE_MODEL_RATES, - VALID_OPENAI_IMAGE_MODELS, - VALID_SIZES_BY_MODEL, - DEBUG_MODE, -) -from .tracker import ConsoleStepTracker - -logger = logging.getLogger(__name__) - - -class AICore: - """ - Centralized AI operations handler with console logging. - All AI requests go through run_ai_request() for consistent execution and logging. - """ - - def __init__(self, account=None): - """ - Initialize AICore with account context. - - Args: - account: Optional account object for API key/model loading - """ - self.account = account - self._openai_api_key = None - self._runware_api_key = None - self._default_model = None - self._load_account_settings() - - def _load_account_settings(self): - """Load API keys and model from IntegrationSettings or Django settings""" - if self.account: - try: - from igny8_core.modules.system.models import IntegrationSettings - - # Load OpenAI settings - openai_settings = IntegrationSettings.objects.filter( - integration_type='openai', - account=self.account, - is_active=True - ).first() - if openai_settings and openai_settings.config: - self._openai_api_key = openai_settings.config.get('apiKey') - model = openai_settings.config.get('model') - if model: - if model in MODEL_RATES: - self._default_model = model - logger.info(f"Loaded model '{model}' from IntegrationSettings for account {self.account.id}") - else: - error_msg = f"Model '{model}' from IntegrationSettings is not in supported models list. Supported models: {list(MODEL_RATES.keys())}" - logger.error(f"[AICore] {error_msg}") - logger.error(f"[AICore] Account {self.account.id} has invalid model configuration. Please update Integration Settings.") - # Don't set _default_model, will fall back to Django settings - else: - logger.warning(f"No model configured in IntegrationSettings for account {self.account.id}, will use fallback") - - # Load Runware settings - runware_settings = IntegrationSettings.objects.filter( - integration_type='runware', - account=self.account, - is_active=True - ).first() - if runware_settings and runware_settings.config: - self._runware_api_key = runware_settings.config.get('apiKey') - except Exception as e: - logger.warning(f"Could not load account settings: {e}", exc_info=True) - - # Fallback to Django settings - if not self._openai_api_key: - self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None) - if not self._runware_api_key: - self._runware_api_key = getattr(settings, 'RUNWARE_API_KEY', None) - if not self._default_model: - self._default_model = getattr(settings, 'DEFAULT_AI_MODEL', DEFAULT_AI_MODEL) - - def get_api_key(self, integration_type: str = 'openai') -> Optional[str]: - """Get API key for integration type""" - if integration_type == 'openai': - return self._openai_api_key - elif integration_type == 'runware': - return self._runware_api_key - return None - - def get_model(self, integration_type: str = 'openai') -> str: - """Get model for integration type""" - if integration_type == 'openai': - return self._default_model - return DEFAULT_AI_MODEL - - def run_ai_request( - self, - prompt: str, - model: Optional[str] = None, - max_tokens: int = 4000, - temperature: float = 0.7, - response_format: Optional[Dict] = None, - api_key: Optional[str] = None, - function_name: str = 'ai_request', - function_id: Optional[str] = None, - tracker: Optional[ConsoleStepTracker] = None - ) -> Dict[str, Any]: - """ - Centralized AI request handler with console logging. - All AI text generation requests go through this method. - - Args: - prompt: Prompt text - model: Model name (defaults to account's default) - max_tokens: Maximum tokens - temperature: Temperature (0-1) - response_format: Optional response format dict (for JSON mode) - api_key: Optional API key override - function_name: Function name for logging (e.g., 'cluster_keywords') - tracker: Optional ConsoleStepTracker instance for logging - - Returns: - Dict with 'content', 'input_tokens', 'output_tokens', 'total_tokens', - 'model', 'cost', 'error', 'api_id' - """ - # Use provided tracker or create a new one - if tracker is None: - tracker = ConsoleStepTracker(function_name) - - tracker.ai_call("Preparing request...") - - # Step 1: Validate API key - api_key = api_key or self._openai_api_key - if not api_key: - error_msg = 'OpenAI API key not configured' - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': model or self._default_model, - 'cost': 0.0, - 'api_id': None, - } - - # Step 2: Determine model - active_model = model or self._default_model - - # Debug logging: Show model from settings vs model used - model_from_settings = self._default_model - model_used = active_model - logger.info(f"[AICore] Model Configuration Debug:") - logger.info(f" - Model from IntegrationSettings: {model_from_settings}") - logger.info(f" - Model parameter passed: {model}") - logger.info(f" - Model actually used in request: {model_used}") - tracker.ai_call(f"Model Debug - Settings: {model_from_settings}, Parameter: {model}, Using: {model_used}") - - # Validate model is available and supported - if not active_model: - error_msg = 'No AI model configured. Please configure a model in Integration Settings or Django settings.' - logger.error(f"[AICore] {error_msg}") - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': None, - 'cost': 0.0, - 'api_id': None, - } - - if active_model not in MODEL_RATES: - error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}" - logger.error(f"[AICore] {error_msg}") - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - tracker.ai_call(f"Using model: {active_model}") - - # Step 3: Auto-enable JSON mode for supported models - if response_format is None and active_model in JSON_MODE_MODELS: - response_format = {'type': 'json_object'} - tracker.ai_call(f"Auto-enabled JSON mode for {active_model}") - elif response_format: - tracker.ai_call(f"Using custom response format: {response_format}") - else: - tracker.ai_call("Using text response format") - - # Step 4: Validate prompt length and add function_id - prompt_length = len(prompt) - tracker.ai_call(f"Prompt length: {prompt_length} characters") - - # Add function_id to prompt if provided (for tracking) - final_prompt = prompt - if function_id: - function_id_prefix = f'function_id: "{function_id}"\n\n' - final_prompt = function_id_prefix + prompt - tracker.ai_call(f"Added function_id to prompt: {function_id}") - - # Step 5: Build request payload - url = 'https://api.openai.com/v1/chat/completions' - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - } - - body_data = { - 'model': active_model, - 'messages': [{'role': 'user', 'content': final_prompt}], - 'temperature': temperature, - } - - if max_tokens: - body_data['max_tokens'] = max_tokens - - if response_format: - body_data['response_format'] = response_format - - tracker.ai_call(f"Request payload prepared (model={active_model}, max_tokens={max_tokens}, temp={temperature})") - - # Step 6: Send request - tracker.ai_call("Sending request to OpenAI API...") - request_start = time.time() - - try: - response = requests.post(url, headers=headers, json=body_data, timeout=60) - request_duration = time.time() - request_start - tracker.ai_call(f"Received response in {request_duration:.2f}s (status={response.status_code})") - - # Step 7: Validate HTTP response - if response.status_code != 200: - error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} - error_message = f"HTTP {response.status_code} error" - - if isinstance(error_data, dict) and 'error' in error_data: - if isinstance(error_data['error'], dict) and 'message' in error_data['error']: - error_message += f": {error_data['error']['message']}" - - # Check for rate limit - if response.status_code == 429: - retry_after = response.headers.get('retry-after', '60') - tracker.rate_limit(retry_after) - error_message += f" (Rate limit - retry after {retry_after}s)" - else: - tracker.error('HTTPError', error_message) - - logger.error(f"OpenAI API HTTP error {response.status_code}: {error_message}") - - return { - 'content': None, - 'error': error_message, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - # Step 8: Parse response JSON - try: - data = response.json() - except json.JSONDecodeError as e: - error_msg = f'Failed to parse JSON response: {str(e)}' - tracker.malformed_json(str(e)) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - api_id = data.get('id') - - # Step 9: Extract content - if 'choices' in data and len(data['choices']) > 0: - content = data['choices'][0]['message']['content'] - usage = data.get('usage', {}) - input_tokens = usage.get('prompt_tokens', 0) - output_tokens = usage.get('completion_tokens', 0) - total_tokens = usage.get('total_tokens', 0) - - tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})") - tracker.parse(f"Content length: {len(content)} characters") - - # Step 10: Calculate cost - rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00}) - cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000 - tracker.parse(f"Cost calculated: ${cost:.6f}") - - tracker.done("Request completed successfully") - - return { - 'content': content, - 'input_tokens': input_tokens, - 'output_tokens': output_tokens, - 'total_tokens': total_tokens, - 'model': active_model, - 'cost': cost, - 'error': None, - 'api_id': api_id, - 'duration': request_duration, # Add duration tracking - } - else: - error_msg = 'No content in OpenAI response' - tracker.error('EmptyResponse', error_msg) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': api_id, - } - - except requests.exceptions.Timeout: - error_msg = 'Request timeout (60s exceeded)' - tracker.timeout(60) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - except requests.exceptions.RequestException as e: - error_msg = f'Request exception: {str(e)}' - tracker.error('RequestException', error_msg, e) - logger.error(f"OpenAI API error: {error_msg}", exc_info=True) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - logger.error(f"[AI][{function_name}][Error] {error_msg}", exc_info=True) - if tracker: - tracker.error('UnexpectedError', error_msg, e) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - def extract_json(self, response_text: str) -> Optional[Dict]: - """ - Extract JSON from response text. - Handles markdown code blocks, multiline JSON, etc. - - Args: - response_text: Raw response text from AI - - Returns: - Parsed JSON dict or None - """ - if not response_text or not response_text.strip(): - return None - - # Try direct JSON parse first - try: - return json.loads(response_text.strip()) - except json.JSONDecodeError: - pass - - # Try to extract JSON from markdown code blocks - json_block_pattern = r'```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```' - matches = re.findall(json_block_pattern, response_text, re.DOTALL) - if matches: - try: - return json.loads(matches[0]) - except json.JSONDecodeError: - pass - - # Try to find JSON object/array in text - json_pattern = r'(\{.*\}|\[.*\])' - matches = re.findall(json_pattern, response_text, re.DOTALL) - for match in matches: - try: - return json.loads(match) - except json.JSONDecodeError: - continue - - return None - - def generate_image( - self, - prompt: str, - provider: str = 'openai', - model: Optional[str] = None, - size: str = '1024x1024', - n: int = 1, - api_key: Optional[str] = None, - negative_prompt: Optional[str] = None, - function_name: str = 'generate_image' - ) -> Dict[str, Any]: - """ - Generate image using AI with console logging. - - Args: - prompt: Image prompt - provider: 'openai' or 'runware' - model: Model name - size: Image size - n: Number of images - api_key: Optional API key override - negative_prompt: Optional negative prompt - function_name: Function name for logging - - Returns: - Dict with 'url', 'revised_prompt', 'cost', 'error', etc. - """ - print(f"[AI][{function_name}] Step 1: Preparing image generation request...") - - if provider == 'openai': - return self._generate_image_openai(prompt, model, size, n, api_key, negative_prompt, function_name) - elif provider == 'runware': - return self._generate_image_runware(prompt, model, size, n, api_key, negative_prompt, function_name) - else: - error_msg = f'Unknown provider: {provider}' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': provider, - 'cost': 0.0, - 'error': error_msg, - } - - def _generate_image_openai( - self, - prompt: str, - model: Optional[str], - size: str, - n: int, - api_key: Optional[str], - negative_prompt: Optional[str], - function_name: str - ) -> Dict[str, Any]: - """Generate image using OpenAI DALL-E""" - print(f"[AI][{function_name}] Provider: OpenAI") - - api_key = api_key or self._openai_api_key - if not api_key: - error_msg = 'OpenAI API key not configured' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - model = model or 'dall-e-3' - print(f"[AI][{function_name}] Step 2: Using model: {model}, size: {size}") - - # Validate model - if model not in VALID_OPENAI_IMAGE_MODELS: - error_msg = f"Model '{model}' is not valid for OpenAI image generation. Only {', '.join(VALID_OPENAI_IMAGE_MODELS)} are supported." - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - # Validate size - valid_sizes = VALID_SIZES_BY_MODEL.get(model, []) - if size not in valid_sizes: - error_msg = f"Image size '{size}' is not valid for model '{model}'. Valid sizes: {', '.join(valid_sizes)}" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - url = 'https://api.openai.com/v1/images/generations' - print(f"[AI][{function_name}] Step 3: Sending request to OpenAI Images API...") - - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - } - - data = { - 'model': model, - 'prompt': prompt, - 'n': n, - 'size': size - } - - if negative_prompt: - # Note: OpenAI DALL-E doesn't support negative_prompt in API, but we log it - print(f"[AI][{function_name}] Note: Negative prompt provided but OpenAI DALL-E doesn't support it") - - request_start = time.time() - try: - response = requests.post(url, headers=headers, json=data, timeout=150) - request_duration = time.time() - request_start - print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})") - - if response.status_code != 200: - error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} - error_message = f"HTTP {response.status_code} error" - if isinstance(error_data, dict) and 'error' in error_data: - if isinstance(error_data['error'], dict) and 'message' in error_data['error']: - error_message += f": {error_data['error']['message']}" - - print(f"[AI][{function_name}][Error] {error_message}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_message, - } - - body = response.json() - if 'data' in body and len(body['data']) > 0: - image_data = body['data'][0] - image_url = image_data.get('url') - revised_prompt = image_data.get('revised_prompt') - - cost = IMAGE_MODEL_RATES.get(model, 0.040) * n - print(f"[AI][{function_name}] Step 5: Image generated successfully") - print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}") - print(f"[AI][{function_name}][Success] Image generation completed") - - return { - 'url': image_url, - 'revised_prompt': revised_prompt, - 'provider': 'openai', - 'cost': cost, - 'error': None, - } - else: - error_msg = 'No image data in response' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - except requests.exceptions.Timeout: - error_msg = 'Request timeout (150s exceeded)' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - print(f"[AI][{function_name}][Error] {error_msg}") - logger.error(error_msg, exc_info=True) - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - def _generate_image_runware( - self, - prompt: str, - model: Optional[str], - size: str, - n: int, - api_key: Optional[str], - negative_prompt: Optional[str], - function_name: str - ) -> Dict[str, Any]: - """Generate image using Runware""" - print(f"[AI][{function_name}] Provider: Runware") - - api_key = api_key or self._runware_api_key - if not api_key: - error_msg = 'Runware API key not configured' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - runware_model = model or 'runware:97@1' - print(f"[AI][{function_name}] Step 2: Using model: {runware_model}, size: {size}") - - # Parse size - try: - width, height = map(int, size.split('x')) - except ValueError: - error_msg = f"Invalid size format: {size}. Expected format: WIDTHxHEIGHT" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - url = 'https://api.runware.ai/v1' - print(f"[AI][{function_name}] Step 3: Sending request to Runware API...") - - # Runware uses array payload - payload = [{ - 'taskType': 'imageInference', - 'model': runware_model, - 'prompt': prompt, - 'width': width, - 'height': height, - 'apiKey': api_key - }] - - if negative_prompt: - payload[0]['negativePrompt'] = negative_prompt - - request_start = time.time() - try: - response = requests.post(url, json=payload, timeout=150) - request_duration = time.time() - request_start - print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})") - - if response.status_code != 200: - error_msg = f"HTTP {response.status_code} error" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - body = response.json() - # Runware returns array with image data - if isinstance(body, list) and len(body) > 0: - image_data = body[0] - image_url = image_data.get('imageURL') or image_data.get('url') - - cost = 0.036 * n # Runware pricing - print(f"[AI][{function_name}] Step 5: Image generated successfully") - print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}") - print(f"[AI][{function_name}][Success] Image generation completed") - - return { - 'url': image_url, - 'provider': 'runware', - 'cost': cost, - 'error': None, - } - else: - error_msg = 'No image data in Runware response' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - print(f"[AI][{function_name}][Error] {error_msg}") - logger.error(error_msg, exc_info=True) - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - def calculate_cost(self, model: str, input_tokens: int, output_tokens: int, model_type: str = 'text') -> float: - """Calculate cost for API call""" - if model_type == 'text': - rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00}) - input_cost = (input_tokens / 1_000_000) * rates['input'] - output_cost = (output_tokens / 1_000_000) * rates['output'] - return input_cost + output_cost - elif model_type == 'image': - rate = IMAGE_MODEL_RATES.get(model, 0.040) - return rate * 1 - return 0.0 - - # Legacy method names for backward compatibility - def call_openai(self, prompt: str, model: Optional[str] = None, max_tokens: int = 4000, - temperature: float = 0.7, response_format: Optional[Dict] = None, - api_key: Optional[str] = None) -> Dict[str, Any]: - """Legacy method - redirects to run_ai_request()""" - return self.run_ai_request( - prompt=prompt, - model=model, - max_tokens=max_tokens, - temperature=temperature, - response_format=response_format, - api_key=api_key, - function_name='call_openai' - ) diff --git a/backend/igny8_core/ai/helpers/base.py b/backend/igny8_core/ai/helpers/base.py deleted file mode 100644 index 20f79613..00000000 --- a/backend/igny8_core/ai/helpers/base.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Base class for all AI functions -""" -from abc import ABC, abstractmethod -from typing import Dict, List, Any, Optional - - -class BaseAIFunction(ABC): - """ - Base class for all AI functions. - Each function only implements its specific logic. - """ - - @abstractmethod - def get_name(self) -> str: - """Return function name (e.g., 'auto_cluster')""" - pass - - def get_metadata(self) -> Dict: - """Return function metadata (display name, description, phases)""" - return { - 'display_name': self.get_name().replace('_', ' ').title(), - 'description': f'{self.get_name()} AI function', - 'phases': { - 'INIT': 'Initializing...', - 'PREP': 'Preparing data...', - 'AI_CALL': 'Processing with AI...', - 'PARSE': 'Parsing response...', - 'SAVE': 'Saving results...', - 'DONE': 'Complete!' - } - } - - def validate(self, payload: dict, account=None) -> Dict[str, Any]: - """ - Validate input payload. - Default: checks for 'ids' array. - Override for custom validation. - """ - ids = payload.get('ids', []) - if not ids: - return {'valid': False, 'error': 'No IDs provided'} - - # Removed max_items limit check - no limits enforced - - return {'valid': True} - - def get_max_items(self) -> Optional[int]: - """Override to set max items limit""" - return None - - @abstractmethod - def prepare(self, payload: dict, account=None) -> Any: - """ - Load and prepare data for AI processing. - Returns: prepared data structure - """ - pass - - @abstractmethod - def build_prompt(self, data: Any, account=None) -> str: - """ - Build AI prompt from prepared data. - Returns: prompt string - """ - pass - - def get_model(self, account=None) -> Optional[str]: - """Override to specify model (defaults to account's default model)""" - return None # Uses account's default from AIProcessor - - @abstractmethod - def parse_response(self, response: str, step_tracker=None) -> Any: - """ - Parse AI response into structured data. - Returns: parsed data structure - """ - pass - - @abstractmethod - def save_output( - self, - parsed: Any, - original_data: Any, - account=None, - progress_tracker=None, - step_tracker=None - ) -> Dict: - """ - Save parsed results to database. - Returns: dict with 'count', 'items_created', etc. - """ - pass - diff --git a/backend/igny8_core/ai/helpers/models.py b/backend/igny8_core/ai/helpers/models.py deleted file mode 100644 index 2ee444f0..00000000 --- a/backend/igny8_core/ai/helpers/models.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -AI Framework Models -""" -from django.db import models -from igny8_core.auth.models import AccountBaseModel - - -class AITaskLog(AccountBaseModel): - """ - Unified logging table for all AI tasks. - Stores request/response steps, costs, tokens, and results. - """ - task_id = models.CharField(max_length=255, db_index=True, null=True, blank=True) - function_name = models.CharField(max_length=100, db_index=True) - phase = models.CharField(max_length=50, default='INIT') - message = models.TextField(blank=True) - status = models.CharField(max_length=20, choices=[ - ('success', 'Success'), - ('error', 'Error'), - ('pending', 'Pending'), - ], default='pending') - - # Timing - duration = models.IntegerField(null=True, blank=True, help_text="Duration in milliseconds") - - # Cost tracking - cost = models.DecimalField(max_digits=10, decimal_places=6, default=0.0) - tokens = models.IntegerField(default=0) - - # Step tracking - request_steps = models.JSONField(default=list, blank=True) - response_steps = models.JSONField(default=list, blank=True) - - # Error tracking - error = models.TextField(null=True, blank=True) - - # Data - payload = models.JSONField(null=True, blank=True) - result = models.JSONField(null=True, blank=True) - - class Meta: - db_table = 'igny8_ai_task_logs' - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['task_id']), - models.Index(fields=['function_name', 'account']), - models.Index(fields=['status', 'created_at']), - ] - - def __str__(self): - return f"{self.function_name} - {self.status} - {self.created_at}" - diff --git a/backend/igny8_core/ai/helpers/settings.py b/backend/igny8_core/ai/helpers/settings.py deleted file mode 100644 index 2b589ce7..00000000 --- a/backend/igny8_core/ai/helpers/settings.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -AI Settings - Centralized model configurations and limits -""" -from typing import Dict, Any - -# Model configurations for each AI function -MODEL_CONFIG = { - "auto_cluster": { - "model": "gpt-4o-mini", - "max_tokens": 3000, - "temperature": 0.7, - "response_format": {"type": "json_object"}, # Auto-enabled for JSON mode models - }, - "generate_ideas": { - "model": "gpt-4.1", - "max_tokens": 4000, - "temperature": 0.7, - "response_format": {"type": "json_object"}, # JSON output - }, - "generate_content": { - "model": "gpt-4.1", - "max_tokens": 8000, - "temperature": 0.7, - "response_format": {"type": "json_object"}, # JSON output - }, - "generate_images": { - "model": "dall-e-3", - "size": "1024x1024", - "provider": "openai", - }, - "extract_image_prompts": { - "model": "gpt-4o-mini", - "max_tokens": 1000, - "temperature": 0.7, - "response_format": {"type": "json_object"}, - }, -} - -# Function name aliases (for backward compatibility) -FUNCTION_ALIASES = { - "cluster_keywords": "auto_cluster", - "auto_cluster_keywords": "auto_cluster", - "auto_generate_ideas": "generate_ideas", - "auto_generate_content": "generate_content", - "auto_generate_images": "generate_images", -} - - -def get_model_config(function_name: str, account=None) -> Dict[str, Any]: - """ - Get model configuration for an AI function. - Reads model from IntegrationSettings if account is provided, otherwise uses defaults. - - Args: - function_name: AI function name (e.g., 'auto_cluster', 'generate_ideas') - account: Optional account object to read model from IntegrationSettings - - Returns: - Dict with model, max_tokens, temperature, etc. - """ - # Check aliases first - actual_name = FUNCTION_ALIASES.get(function_name, function_name) - - # Get base config - config = MODEL_CONFIG.get(actual_name, {}).copy() - - # Try to get model from IntegrationSettings if account is provided - model_from_settings = None - if account: - try: - from igny8_core.modules.system.models import IntegrationSettings - openai_settings = IntegrationSettings.objects.filter( - integration_type='openai', - account=account, - is_active=True - ).first() - if openai_settings and openai_settings.config: - model_from_settings = openai_settings.config.get('model') - if model_from_settings: - # Validate model is in our supported list - from igny8_core.utils.ai_processor import MODEL_RATES - if model_from_settings in MODEL_RATES: - config['model'] = model_from_settings - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.warning(f"Could not load model from IntegrationSettings: {e}", exc_info=True) - - # Merge with defaults - default_config = { - "model": "gpt-4.1", - "max_tokens": 4000, - "temperature": 0.7, - "response_format": None, - } - - return {**default_config, **config} - - -def get_model(function_name: str) -> str: - """Get model name for function""" - config = get_model_config(function_name) - return config.get("model", "gpt-4.1") - - -def get_max_tokens(function_name: str) -> int: - """Get max tokens for function""" - config = get_model_config(function_name) - return config.get("max_tokens", 4000) - - -def get_temperature(function_name: str) -> float: - """Get temperature for function""" - config = get_model_config(function_name) - return config.get("temperature", 0.7) - diff --git a/backend/igny8_core/ai/helpers/tracker.py b/backend/igny8_core/ai/helpers/tracker.py deleted file mode 100644 index bb75206c..00000000 --- a/backend/igny8_core/ai/helpers/tracker.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -Progress and Step Tracking utilities for AI framework -""" -import time -import logging -from typing import List, Dict, Any, Optional, Callable -from datetime import datetime -from igny8_core.ai.types import StepLog, ProgressState -from igny8_core.ai.constants import DEBUG_MODE - -logger = logging.getLogger(__name__) - - -class StepTracker: - """Tracks detailed request and response steps for debugging""" - - def __init__(self, function_name: str): - self.function_name = function_name - self.request_steps: List[Dict] = [] - self.response_steps: List[Dict] = [] - self.step_counter = 0 - - def add_request_step( - self, - step_name: str, - status: str = 'success', - message: str = '', - error: str = None, - duration: int = None - ) -> Dict: - """Add a request step with automatic timing""" - self.step_counter += 1 - step = { - 'stepNumber': self.step_counter, - 'stepName': step_name, - 'functionName': self.function_name, - 'status': status, - 'message': message, - 'duration': duration - } - if error: - step['error'] = error - - self.request_steps.append(step) - return step - - def add_response_step( - self, - step_name: str, - status: str = 'success', - message: str = '', - error: str = None, - duration: int = None - ) -> Dict: - """Add a response step with automatic timing""" - self.step_counter += 1 - step = { - 'stepNumber': self.step_counter, - 'stepName': step_name, - 'functionName': self.function_name, - 'status': status, - 'message': message, - 'duration': duration - } - if error: - step['error'] = error - - self.response_steps.append(step) - return step - - def get_meta(self) -> Dict: - """Get metadata for progress callback""" - return { - 'request_steps': self.request_steps, - 'response_steps': self.response_steps - } - - -class ProgressTracker: - """Tracks progress updates for AI tasks""" - - def __init__(self, celery_task=None): - self.task = celery_task - self.current_phase = 'INIT' - self.current_message = 'Initializing...' - self.current_percentage = 0 - self.start_time = time.time() - self.current = 0 - self.total = 0 - - def update( - self, - phase: str, - percentage: int, - message: str, - current: int = None, - total: int = None, - current_item: str = None, - meta: Dict = None - ): - """Update progress with consistent format""" - self.current_phase = phase - self.current_message = message - self.current_percentage = percentage - - if current is not None: - self.current = current - if total is not None: - self.total = total - - progress_meta = { - 'phase': phase, - 'percentage': percentage, - 'message': message, - 'current': self.current, - 'total': self.total, - } - - if current_item: - progress_meta['current_item'] = current_item - - if meta: - progress_meta.update(meta) - - # Update Celery task state if available - if self.task: - try: - self.task.update_state( - state='PROGRESS', - meta=progress_meta - ) - except Exception as e: - logger.warning(f"Failed to update Celery task state: {e}") - - logger.info(f"[{phase}] {percentage}%: {message}") - - def set_phase(self, phase: str, percentage: int, message: str, meta: Dict = None): - """Set progress phase""" - self.update(phase, percentage, message, meta=meta) - - def complete(self, message: str = "Task complete!", meta: Dict = None): - """Mark task as complete""" - final_meta = { - 'phase': 'DONE', - 'percentage': 100, - 'message': message, - 'status': 'success' - } - if meta: - final_meta.update(meta) - - if self.task: - try: - self.task.update_state( - state='SUCCESS', - meta=final_meta - ) - except Exception as e: - logger.warning(f"Failed to update Celery task state: {e}") - - def error(self, error_message: str, meta: Dict = None): - """Mark task as failed""" - error_meta = { - 'phase': 'ERROR', - 'percentage': 0, - 'message': f'Error: {error_message}', - 'status': 'error', - 'error': error_message - } - if meta: - error_meta.update(meta) - - if self.task: - try: - self.task.update_state( - state='FAILURE', - meta=error_meta - ) - except Exception as e: - logger.warning(f"Failed to update Celery task state: {e}") - - def get_duration(self) -> int: - """Get elapsed time in milliseconds""" - return int((time.time() - self.start_time) * 1000) - - def update_ai_progress(self, state: str, meta: Dict): - """Callback for AI processor progress updates""" - if isinstance(meta, dict): - percentage = meta.get('percentage', self.current_percentage) - message = meta.get('message', self.current_message) - phase = meta.get('phase', self.current_phase) - self.update(phase, percentage, message, meta=meta) - - -class CostTracker: - """Tracks API costs and token usage""" - - def __init__(self): - self.total_cost = 0.0 - self.total_tokens = 0 - self.operations = [] - - def record(self, function_name: str, cost: float, tokens: int, model: str = None): - """Record an API call cost""" - self.total_cost += cost - self.total_tokens += tokens - self.operations.append({ - 'function': function_name, - 'cost': cost, - 'tokens': tokens, - 'model': model - }) - - def get_total(self) -> float: - """Get total cost""" - return self.total_cost - - def get_total_tokens(self) -> int: - """Get total tokens""" - return self.total_tokens - - def get_operations(self) -> List[Dict]: - """Get all operations""" - return self.operations - - -class ConsoleStepTracker: - """ - Lightweight console-based step tracker for AI functions. - Logs each step to console with timestamps and clear labels. - Only logs if DEBUG_MODE is True. - """ - - def __init__(self, function_name: str): - self.function_name = function_name - self.start_time = time.time() - self.steps = [] - self.current_phase = None - - # Debug: Verify DEBUG_MODE is enabled - import sys - if DEBUG_MODE: - init_msg = f"[DEBUG] ConsoleStepTracker initialized for '{function_name}' - DEBUG_MODE is ENABLED" - logger.info(init_msg) - print(init_msg, flush=True, file=sys.stdout) - else: - init_msg = f"[WARNING] ConsoleStepTracker initialized for '{function_name}' - DEBUG_MODE is DISABLED" - logger.warning(init_msg) - print(init_msg, flush=True, file=sys.stdout) - - def _log(self, phase: str, message: str, status: str = 'info'): - """Internal logging method that checks DEBUG_MODE""" - if not DEBUG_MODE: - return - - import sys - timestamp = datetime.now().strftime('%H:%M:%S') - phase_label = phase.upper() - - if status == 'error': - log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] [ERROR] {message}" - # Use logger.error for errors so they're always visible - logger.error(log_msg) - elif status == 'success': - log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] ✅ {message}" - logger.info(log_msg) - else: - log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] {message}" - logger.info(log_msg) - - # Also print to stdout for immediate visibility (works in Celery worker logs) - print(log_msg, flush=True, file=sys.stdout) - - self.steps.append({ - 'timestamp': timestamp, - 'phase': phase, - 'message': message, - 'status': status - }) - self.current_phase = phase - - def init(self, message: str = "Task started"): - """Log initialization phase""" - self._log('INIT', message) - - def prep(self, message: str): - """Log preparation phase""" - self._log('PREP', message) - - def ai_call(self, message: str): - """Log AI call phase""" - self._log('AI_CALL', message) - - def parse(self, message: str): - """Log parsing phase""" - self._log('PARSE', message) - - def save(self, message: str): - """Log save phase""" - self._log('SAVE', message) - - def done(self, message: str = "Execution completed"): - """Log completion""" - duration = time.time() - self.start_time - self._log('DONE', f"{message} (Duration: {duration:.2f}s)", status='success') - if DEBUG_MODE: - import sys - complete_msg = f"[{self.function_name}] === AI Task Complete ===" - logger.info(complete_msg) - print(complete_msg, flush=True, file=sys.stdout) - - def error(self, error_type: str, message: str, exception: Exception = None): - """Log error with standardized format""" - error_msg = f"{error_type} – {message}" - if exception: - error_msg += f" ({type(exception).__name__})" - self._log(self.current_phase or 'ERROR', error_msg, status='error') - if DEBUG_MODE and exception: - import sys - import traceback - error_trace_msg = f"[{self.function_name}] [ERROR] Stack trace:" - logger.error(error_trace_msg, exc_info=exception) - print(error_trace_msg, flush=True, file=sys.stdout) - traceback.print_exc(file=sys.stdout) - - def retry(self, attempt: int, max_attempts: int, reason: str = ""): - """Log retry attempt""" - msg = f"Retry attempt {attempt}/{max_attempts}" - if reason: - msg += f" – {reason}" - self._log('AI_CALL', msg, status='info') - - def timeout(self, timeout_seconds: int): - """Log timeout""" - self.error('Timeout', f"Request timeout after {timeout_seconds}s") - - def rate_limit(self, retry_after: str): - """Log rate limit""" - self.error('RateLimit', f"OpenAI rate limit hit, retry in {retry_after}s") - - def malformed_json(self, details: str = ""): - """Log JSON parsing error""" - msg = "Failed to parse model response: Unexpected JSON" - if details: - msg += f" – {details}" - self.error('MalformedJSON', msg) - diff --git a/backend/igny8_core/ai/templates/__init__.py b/backend/igny8_core/ai/templates/__init__.py deleted file mode 100644 index 37b2dcd9..00000000 --- a/backend/igny8_core/ai/templates/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -AI Templates -Template files for reference when creating new AI functions. -""" - diff --git a/backend/igny8_core/ai/templates/ai_functions_template.py b/backend/igny8_core/ai/templates/ai_functions_template.py deleted file mode 100644 index 1e1b3bd7..00000000 --- a/backend/igny8_core/ai/templates/ai_functions_template.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -AI Functions Template -Template/Reference file showing the common pattern used by auto_cluster, generate_ideas, and generate_content. -This is a reference template - do not modify existing functions, use this as a guide for new functions. -""" -import logging -from typing import Dict, List, Any, Optional -from igny8_core.auth.models import Account -from igny8_core.ai.helpers.base import BaseAIFunction -from igny8_core.ai.helpers.ai_core import AICore -from igny8_core.ai.helpers.tracker import ConsoleStepTracker -from igny8_core.ai.helpers.settings import get_model_config - -logger = logging.getLogger(__name__) - - -def ai_function_core_template( - function_class: BaseAIFunction, - function_name: str, - payload: Dict[str, Any], - account_id: Optional[int] = None, - progress_callback: Optional[callable] = None, - **kwargs -) -> Dict[str, Any]: - """ - Template for AI function core logic (legacy function signature pattern). - - This template shows the common pattern used by: - - generate_ideas_core - - generate_content_core - - auto_cluster (via engine, but similar pattern) - - Usage Example: - def my_function_core(item_id: int, account_id: int = None, progress_callback=None): - fn = MyFunctionClass() - payload = {'ids': [item_id]} - return ai_function_core_template( - function_class=fn, - function_name='my_function', - payload=payload, - account_id=account_id, - progress_callback=progress_callback - ) - - Args: - function_class: Instance of the AI function class (e.g., GenerateIdeasFunction()) - function_name: Function name for config/tracking (e.g., 'generate_ideas') - payload: Payload dict with 'ids' and other function-specific data - account_id: Optional account ID for account isolation - progress_callback: Optional progress callback for Celery tasks - **kwargs: Additional function-specific parameters - - Returns: - Dict with 'success', function-specific result fields, 'message', etc. - """ - # Initialize tracker - tracker = ConsoleStepTracker(function_name) - tracker.init("Task started") - - try: - # Load account - account = None - if account_id: - account = Account.objects.get(id=account_id) - - tracker.prep("Loading account data...") - - # Store account on function instance - function_class.account = account - - # Validate - tracker.prep("Validating input...") - validated = function_class.validate(payload, account) - if not validated['valid']: - tracker.error('ValidationError', validated['error']) - return {'success': False, 'error': validated['error']} - - # Prepare data - tracker.prep("Preparing data...") - data = function_class.prepare(payload, account) - - # Build prompt - tracker.prep("Building prompt...") - prompt = function_class.build_prompt(data, account) - - # Get model config from settings - model_config = get_model_config(function_name) - - # Generate function_id for tracking (ai_ prefix with function name) - function_id = f"ai_{function_name}" - - # Call AI using centralized request handler - ai_core = AICore(account=account) - result = ai_core.run_ai_request( - prompt=prompt, - model=model_config.get('model'), - max_tokens=model_config.get('max_tokens'), - temperature=model_config.get('temperature'), - response_format=model_config.get('response_format'), - function_name=function_name, - function_id=function_id, - tracker=tracker - ) - - if result.get('error'): - return {'success': False, 'error': result['error']} - - # Parse response - tracker.parse("Parsing AI response...") - parsed = function_class.parse_response(result['content'], tracker) - - if not parsed: - tracker.error('ParseError', 'No data parsed from AI response') - return {'success': False, 'error': 'No data parsed from AI response'} - - # Handle list responses - if isinstance(parsed, list): - parsed_count = len(parsed) - tracker.parse(f"Parsed {parsed_count} item(s)") - else: - parsed_count = 1 - tracker.parse("Parsed response") - - # Save output - tracker.save("Saving to database...") - save_result = function_class.save_output(parsed, data, account, step_tracker=tracker) - tracker.save(f"Saved {save_result.get('count', 0)} item(s)") - - # Build success message - if isinstance(parsed, list) and len(parsed) > 0: - first_item = parsed[0] - item_name = first_item.get('title') or first_item.get('name') or 'item' - tracker.done(f"Successfully created {item_name}") - message = f"Successfully created {item_name}" - else: - tracker.done("Task completed successfully") - message = "Task completed successfully" - - return { - 'success': True, - **save_result, - 'message': message - } - - except Exception as e: - tracker.error('Exception', str(e), e) - logger.error(f"Error in {function_name}_core: {str(e)}", exc_info=True) - return {'success': False, 'error': str(e)} - - -def ai_function_batch_template( - function_class: BaseAIFunction, - function_name: str, - payload: Dict[str, Any], - account_id: Optional[int] = None, - progress_callback: Optional[callable] = None, - **kwargs -) -> Dict[str, Any]: - """ - Template for AI function batch processing (like generate_content_core). - - This template shows the pattern for functions that process multiple items in a loop. - - Usage Example: - def my_batch_function_core(item_ids: List[int], account_id: int = None, progress_callback=None): - fn = MyFunctionClass() - payload = {'ids': item_ids} - return ai_function_batch_template( - function_class=fn, - function_name='my_function', - payload=payload, - account_id=account_id, - progress_callback=progress_callback - ) - - Args: - function_class: Instance of the AI function class - function_name: Function name for config/tracking - payload: Payload dict with 'ids' list - account_id: Optional account ID for account isolation - progress_callback: Optional progress callback for Celery tasks - **kwargs: Additional function-specific parameters - - Returns: - Dict with 'success', 'count', 'tasks_updated', 'message', etc. - """ - tracker = ConsoleStepTracker(function_name) - tracker.init("Task started") - - try: - # Load account - account = None - if account_id: - account = Account.objects.get(id=account_id) - - tracker.prep("Loading account data...") - - # Store account on function instance - function_class.account = account - - # Validate - tracker.prep("Validating input...") - validated = function_class.validate(payload, account) - if not validated['valid']: - tracker.error('ValidationError', validated['error']) - return {'success': False, 'error': validated['error']} - - # Prepare data (returns list of items) - tracker.prep("Preparing data...") - items = function_class.prepare(payload, account) - if not isinstance(items, list): - items = [items] - - total_items = len(items) - processed_count = 0 - - tracker.prep(f"Processing {total_items} item(s)...") - - # Get model config once (shared across all items) - model_config = get_model_config(function_name) - # Generate function_id for tracking (ai_ prefix with function name) - function_id = f"ai_{function_name}" - ai_core = AICore(account=account) - - # Process each item - for idx, item in enumerate(items): - try: - # Build prompt for this item - prompt = function_class.build_prompt(item if not isinstance(items, list) else [item], account) - - # Call AI - result = ai_core.run_ai_request( - prompt=prompt, - model=model_config.get('model'), - max_tokens=model_config.get('max_tokens'), - temperature=model_config.get('temperature'), - response_format=model_config.get('response_format'), - function_name=function_name, - function_id=function_id, - tracker=tracker - ) - - if result.get('error'): - logger.error(f"AI error for item {idx + 1}/{total_items}: {result['error']}") - continue - - # Parse response - parsed = function_class.parse_response(result['content'], tracker) - - if not parsed: - logger.warning(f"No data parsed for item {idx + 1}/{total_items}") - continue - - # Save output - save_result = function_class.save_output( - parsed, - item if not isinstance(items, list) else [item], - account, - step_tracker=tracker - ) - - processed_count += save_result.get('count', 0) or save_result.get('tasks_updated', 0) or 0 - - except Exception as e: - logger.error(f"Error processing item {idx + 1}/{total_items}: {str(e)}", exc_info=True) - continue - - tracker.done(f"Processed {processed_count} item(s) successfully") - - return { - 'success': True, - 'count': processed_count, - 'tasks_updated': processed_count, - 'message': f'Task completed: {processed_count} item(s) processed' - } - - except Exception as e: - tracker.error('Exception', str(e), e) - logger.error(f"Error in {function_name}_core: {str(e)}", exc_info=True) - return {'success': False, 'error': str(e)} - diff --git a/backend/igny8_core/ai/templates/modals_template.py b/backend/igny8_core/ai/templates/modals_template.py deleted file mode 100644 index c8d29bf0..00000000 --- a/backend/igny8_core/ai/templates/modals_template.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Modal Configuration Templates for AI Functions -Each function uses the same AIProgressModal component with different configs. -""" - -# Modal configuration templates for each AI function -MODAL_CONFIGS = { - 'auto_cluster': { - 'title': 'Auto Cluster Keywords', - 'function_id': 'ai_auto_cluster', - 'success_title': 'Clustering Complete!', - 'success_message_template': 'Successfully created {clusters_created} clusters and updated {keywords_updated} keywords.', - 'error_title': 'Clustering Failed', - 'error_message_template': 'An error occurred while clustering keywords. Please try again.', - }, - 'generate_ideas': { - 'title': 'Generating Ideas', - 'function_id': 'ai_generate_ideas', - 'success_title': 'Ideas Generated!', - 'success_message_template': 'Successfully generated {ideas_created} content idea(s).', - 'error_title': 'Idea Generation Failed', - 'error_message_template': 'An error occurred while generating ideas. Please try again.', - }, - 'generate_content': { - 'title': 'Generating Content', - 'function_id': 'ai_generate_content', - 'success_title': 'Content Generated!', - 'success_message_template': 'Successfully generated content for {tasks_updated} task(s).', - 'error_title': 'Content Generation Failed', - 'error_message_template': 'An error occurred while generating content. Please try again.', - }, -} - -# Legacy function IDs (for backward compatibility) -LEGACY_FUNCTION_IDS = { - 'generate_ideas': 'ai_generate_ideas', - 'generate_content': 'ai_generate_content', -} - - -def get_modal_config(function_name: str, is_legacy: bool = False) -> dict: - """ - Get modal configuration for an AI function. - - Args: - function_name: Function name (e.g., 'auto_cluster', 'generate_ideas', 'generate_content') - is_legacy: Whether this is a legacy function path - - Returns: - Dict with modal configuration - """ - config = MODAL_CONFIGS.get(function_name, {}).copy() - - # Override function_id for legacy paths - if is_legacy and function_name in LEGACY_FUNCTION_IDS: - config['function_id'] = LEGACY_FUNCTION_IDS[function_name] - - return config - - -def format_success_message(function_name: str, result: dict) -> str: - """ - Format success message based on function result. - - Args: - function_name: Function name - result: Result dict from function execution - - Returns: - Formatted success message - """ - config = MODAL_CONFIGS.get(function_name, {}) - template = config.get('success_message_template', 'Task completed successfully.') - - try: - return template.format(**result) - except KeyError: - # Fallback if template variables don't match - return config.get('success_message_template', 'Task completed successfully.') - diff --git a/frontend/src/components/common/AIProgressModal.tsx b/frontend/src/components/common/AIProgressModal.tsx deleted file mode 100644 index f2f95947..00000000 --- a/frontend/src/components/common/AIProgressModal.tsx +++ /dev/null @@ -1,458 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { Modal } from '../ui/modal'; -import { ProgressBar } from '../ui/progress'; -import Button from '../ui/button/Button'; - -export interface AIProgressModalProps { - isOpen: boolean; - title: string; - percentage: number; // 0-100 - status: 'pending' | 'processing' | 'completed' | 'error'; - message: string; - details?: { - current: number; - total: number; - completed: number; - currentItem?: string; - phase?: string; - }; - onClose?: () => void; - onCancel?: () => void; - taskId?: string; - functionId?: string; // AI function ID for tracking (e.g., "ai-cluster-01") - stepLogs?: Array<{ - stepNumber: number; - stepName: string; - status: string; - message: string; - timestamp?: number; - }>; // Step logs for debugging - config?: { - successTitle?: string; - successMessage?: string; - errorTitle?: string; - errorMessage?: string; - }; -} - -// Generate modal instance ID (increments per modal instance) -let modalInstanceCounter = 0; -const getModalInstanceId = () => { - modalInstanceCounter++; - return `modal-${String(modalInstanceCounter).padStart(2, '0')}`; -}; - -export default function AIProgressModal({ - isOpen, - title, - percentage, - status, - message, - details, - onClose, - onCancel, - taskId, - functionId, - stepLogs = [], - config = {}, -}: AIProgressModalProps) { - // Generate modal instance ID on first render - const modalInstanceIdRef = React.useRef(null); - React.useEffect(() => { - if (!modalInstanceIdRef.current) { - modalInstanceIdRef.current = getModalInstanceId(); - } - }, []); - - const modalInstanceId = modalInstanceIdRef.current || 'modal-01'; - - // Build full function ID with modal instance - const fullFunctionId = functionId ? `${functionId}-${modalInstanceId}` : null; - - // Determine color based on status - const getProgressColor = (): 'primary' | 'success' | 'error' | 'warning' => { - if (status === 'error') return 'error'; - if (status === 'completed') return 'success'; - if (status === 'processing') return 'primary'; - return 'primary'; - }; - - // Success icon (from AlertModal style) - const SuccessIcon = () => ( -
- {/* Light green flower-like outer shape with rounded petals */} -
- {/* Dark green inner circle */} -
- - - -
-
- ); - - // Error icon - const ErrorIcon = () => ( -
- {/* Light red cloud-like background */} -
- {/* Light red circle with red X */} -
- - - -
-
- ); - - // Processing spinner - const ProcessingIcon = () => ( -
- - - - -
- ); - - // Show completion screen with big success icon - if (status === 'completed') { - return ( - {})} - className="max-w-md" - showCloseButton={true} - > -
- {/* Big Success Icon */} - - - {/* Title */} -

- {config.successTitle || title || 'Task Completed!'} -

- - {/* Message */} -

- {config.successMessage || message} -

- - {/* Details if available */} - {details && details.total > 0 && ( -
-
- - {details.completed || details.current} - - {' / '} - - {details.total} - - {' items completed'} -
-
- )} - - {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( -
- {fullFunctionId &&
Function ID: {fullFunctionId}
} - {taskId &&
Task ID: {taskId}
} -
- )} - - {/* Step Logs / Debug Logs */} - {stepLogs.length > 0 && ( -
-
-

- Step Logs -

- - {stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''} - -
-
- {stepLogs.map((step, index) => ( -
-
- - [{step.stepNumber}] - - {step.stepName}: - {step.message} -
-
- ))} -
-
- )} - - {/* Close Button */} -
- -
-
-
- ); - } - - // Show error screen with big error icon - if (status === 'error') { - return ( - {})} - className="max-w-md" - showCloseButton={true} - > -
- {/* Big Error Icon */} - - - {/* Title */} -

- {config.errorTitle || 'Error Occurred'} -

- - {/* Message */} -

- {config.errorMessage || message} -

- - {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( -
- {fullFunctionId &&
Function ID: {fullFunctionId}
} - {taskId &&
Task ID: {taskId}
} -
- )} - - {/* Step Logs / Debug Logs */} - {stepLogs.length > 0 && ( -
-
-

- Step Logs -

- - {stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''} - -
-
- {stepLogs.map((step, index) => ( -
-
- - [{step.stepNumber}] - - {step.stepName}: - {step.message} -
-
- ))} -
-
- )} - - {/* Close Button */} -
- -
-
-
- ); - } - - // Processing/Pending state - show progress modal - return ( - {})} - className="max-w-lg" - showCloseButton={false} - > -
- {/* Header with Processing Icon */} -
- -

- {title} -

-

- {message} -

-
- - {/* Progress Bar */} -
- -
- - {/* Details (current/total) */} - {details && details.total > 0 && ( -
-
- - Progress - - - {details.current || details.completed || 0} / {details.total} - -
- {details.currentItem && ( -
- Current: {details.currentItem} -
- )} -
- )} - - {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( -
- {fullFunctionId && ( -
Function ID: {fullFunctionId}
- )} - {taskId && ( -
Task ID: {taskId}
- )} -
- )} - - {/* Step Logs / Debug Logs */} - {stepLogs.length > 0 && ( -
-
-

- Step Logs -

- - {stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''} - -
-
- {stepLogs.map((step, index) => ( -
-
- - [{step.stepNumber}] - - {step.stepName}: - {step.message} -
-
- ))} -
-
- )} - - {/* Footer */} -
- {onCancel && status !== 'completed' && status !== 'error' && ( - - )} -
-
-
- ); -} -