From 46f5bb4d6235e1568d56f8bbfc1a4de5c0d9aed5 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 10 Nov 2025 22:05:35 +0500 Subject: [PATCH] prep --- .../functions/workflow_functions/__init__.py | 5 + backend/igny8_core/ai/helpers/ai_core.py | 755 ++++++++++++++++++ backend/igny8_core/ai/helpers/base.py | 94 +++ backend/igny8_core/ai/helpers/models.py | 52 ++ backend/igny8_core/ai/helpers/settings.py | 116 +++ backend/igny8_core/ai/helpers/tracker.py | 347 ++++++++ backend/igny8_core/ai/templates/__init__.py | 5 + .../ai/templates/ai_functions_template.py | 281 +++++++ .../ai/templates/modals_template.py | 80 ++ .../src/components/common/AIProgressModal.tsx | 458 +++++++++++ 10 files changed, 2193 insertions(+) create mode 100644 backend/igny8_core/ai/functions/workflow_functions/__init__.py create mode 100644 backend/igny8_core/ai/helpers/ai_core.py create mode 100644 backend/igny8_core/ai/helpers/base.py create mode 100644 backend/igny8_core/ai/helpers/models.py create mode 100644 backend/igny8_core/ai/helpers/settings.py create mode 100644 backend/igny8_core/ai/helpers/tracker.py create mode 100644 backend/igny8_core/ai/templates/__init__.py create mode 100644 backend/igny8_core/ai/templates/ai_functions_template.py create mode 100644 backend/igny8_core/ai/templates/modals_template.py create mode 100644 frontend/src/components/common/AIProgressModal.tsx diff --git a/backend/igny8_core/ai/functions/workflow_functions/__init__.py b/backend/igny8_core/ai/functions/workflow_functions/__init__.py new file mode 100644 index 00000000..981b0267 --- /dev/null +++ b/backend/igny8_core/ai/functions/workflow_functions/__init__.py @@ -0,0 +1,5 @@ +""" +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 new file mode 100644 index 00000000..71488934 --- /dev/null +++ b/backend/igny8_core/ai/helpers/ai_core.py @@ -0,0 +1,755 @@ +""" +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 new file mode 100644 index 00000000..20f79613 --- /dev/null +++ b/backend/igny8_core/ai/helpers/base.py @@ -0,0 +1,94 @@ +""" +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 new file mode 100644 index 00000000..2ee444f0 --- /dev/null +++ b/backend/igny8_core/ai/helpers/models.py @@ -0,0 +1,52 @@ +""" +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 new file mode 100644 index 00000000..2b589ce7 --- /dev/null +++ b/backend/igny8_core/ai/helpers/settings.py @@ -0,0 +1,116 @@ +""" +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 new file mode 100644 index 00000000..bb75206c --- /dev/null +++ b/backend/igny8_core/ai/helpers/tracker.py @@ -0,0 +1,347 @@ +""" +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 new file mode 100644 index 00000000..37b2dcd9 --- /dev/null +++ b/backend/igny8_core/ai/templates/__init__.py @@ -0,0 +1,5 @@ +""" +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 new file mode 100644 index 00000000..1e1b3bd7 --- /dev/null +++ b/backend/igny8_core/ai/templates/ai_functions_template.py @@ -0,0 +1,281 @@ +""" +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 new file mode 100644 index 00000000..c8d29bf0 --- /dev/null +++ b/backend/igny8_core/ai/templates/modals_template.py @@ -0,0 +1,80 @@ +""" +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 new file mode 100644 index 00000000..f2f95947 --- /dev/null +++ b/frontend/src/components/common/AIProgressModal.tsx @@ -0,0 +1,458 @@ +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' && ( + + )} +
+
+
+ ); +} +