diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index 41ee7680..7fc878de 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -485,21 +485,32 @@ class AICore: """Generate image using OpenAI DALL-E""" print(f"[AI][{function_name}] Provider: OpenAI") - # CRITICAL: Truncate prompt to OpenAI's 1000 character limit BEFORE any processing - if len(prompt) > 1000: - print(f"[AI][{function_name}][Warning] Prompt too long ({len(prompt)} chars), truncating to 1000") + # Determine character limit based on model + # DALL-E 2: 1000 chars, DALL-E 3: 4000 chars + model = model or 'dall-e-3' + if model == 'dall-e-2': + max_length = 1000 + elif model == 'dall-e-3': + max_length = 4000 + else: + # Default to 1000 for safety + max_length = 1000 + + # CRITICAL: Truncate prompt to model-specific limit BEFORE any processing + if len(prompt) > max_length: + print(f"[AI][{function_name}][Warning] Prompt too long ({len(prompt)} chars), truncating to {max_length} for {model}") # Try word-aware truncation, but fallback to hard truncate if no space found - truncated = prompt[:997] + truncated = prompt[:max_length - 3] last_space = truncated.rfind(' ') - if last_space > 900: # Only use word-aware if we have a reasonable space + if last_space > max_length * 0.9: # Only use word-aware if we have a reasonable space prompt = truncated[:last_space] + "..." else: - prompt = prompt[:1000] # Hard truncate if no good space found + prompt = prompt[:max_length] # Hard truncate if no good space found print(f"[AI][{function_name}] Truncated prompt length: {len(prompt)}") # Final safety check - if len(prompt) > 1000: - prompt = prompt[:1000] - print(f"[AI][{function_name}][Error] Had to hard truncate to exactly 1000 chars") + if len(prompt) > max_length: + prompt = prompt[:max_length] + print(f"[AI][{function_name}][Error] Had to hard truncate to exactly {max_length} chars") api_key = api_key or self._openai_api_key if not api_key: @@ -675,19 +686,30 @@ class AICore: url = 'https://api.runware.ai/v1' print(f"[AI][{function_name}] Step 3: Sending request to Runware API...") + print(f"[AI][{function_name}] Runware API key check: has_key={bool(api_key)}, key_length={len(api_key) if api_key else 0}") - # 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 + # Runware uses array payload with authentication task first, then imageInference + # Reference: image-generation.php lines 79-97 + import uuid + payload = [ + { + 'taskType': 'authentication', + 'apiKey': api_key + }, + { + 'taskType': 'imageInference', + 'taskUUID': str(uuid.uuid4()), + 'positivePrompt': prompt, + 'negativePrompt': negative_prompt or '', + 'model': runware_model, + 'width': width, + 'height': height, + 'steps': 30, + 'CFGScale': 7.5, + 'numberResults': 1, + 'outputFormat': 'webp' + } + ] request_start = time.time() try: @@ -706,10 +728,28 @@ class AICore: } 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') + print(f"[AI][{function_name}] Runware response type: {type(body)}, length: {len(body) if isinstance(body, list) else 'N/A'}") + + # Runware returns array: [auth_result, image_result] + # image_result has 'data' array with image objects containing 'imageURL' + image_url = None + if isinstance(body, list): + # Find the imageInference result (usually second element) + for item in body: + if isinstance(item, dict): + # Check for 'data' key (image result) + if 'data' in item and isinstance(item['data'], list) and len(item['data']) > 0: + image_data = item['data'][0] + image_url = image_data.get('imageURL') or image_data.get('image_url') + if image_url: + break + # Check for direct 'imageURL' (fallback) + elif 'imageURL' in item: + image_url = item.get('imageURL') + if image_url: + break + + if image_url: cost = 0.036 * n # Runware pricing print(f"[AI][{function_name}] Step 5: Image generated successfully") diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py index 11eccce6..255369cf 100644 --- a/backend/igny8_core/ai/tasks.py +++ b/backend/igny8_core/ai/tasks.py @@ -317,32 +317,44 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None continue # Format template with image prompt from database - # For DALL-E 2: Use image prompt directly (no template) - # For DALL-E 3 and others: Use template with placeholders - # CRITICAL: OpenAI has strict 1000 character limit for prompts + # For DALL-E 2: Use image prompt directly (no template), 1000 char limit + # For DALL-E 3: Use template with placeholders, 4000 char limit + # CRITICAL: DALL-E 2 has 1000 char limit, DALL-E 3 has 4000 char limit image_prompt = image.prompt or "" + # Determine character limit based on model + if model == 'dall-e-2': + max_prompt_length = 1000 + elif model == 'dall-e-3': + max_prompt_length = 4000 + else: + # Default to 1000 for safety + max_prompt_length = 1000 + logger.warning(f"Unknown model '{model}', using 1000 char limit") + + logger.info(f"[process_image_generation_queue] Model: {model}, Max prompt length: {max_prompt_length} chars") + if model == 'dall-e-2': # DALL-E 2: Use image prompt directly, no template logger.info(f"[process_image_generation_queue] Using DALL-E 2 - skipping template, using image prompt directly") formatted_prompt = image_prompt # Truncate to 1000 chars if needed - if len(formatted_prompt) > 1000: - logger.warning(f"DALL-E 2 prompt too long ({len(formatted_prompt)} chars), truncating to 1000") - truncated = formatted_prompt[:997] + if len(formatted_prompt) > max_prompt_length: + logger.warning(f"DALL-E 2 prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length}") + truncated = formatted_prompt[:max_prompt_length - 3] last_space = truncated.rfind(' ') - if last_space > 900: + if last_space > max_prompt_length * 0.9: formatted_prompt = truncated[:last_space] + "..." else: - formatted_prompt = formatted_prompt[:1000] + formatted_prompt = formatted_prompt[:max_prompt_length] else: # DALL-E 3 and others: Use template try: - # Truncate post_title aggressively (max 80 chars to leave more room for image_prompt) + # Truncate post_title (max 200 chars for DALL-E 3 to leave room for image_prompt) post_title = content.title or content.meta_title or f"Content #{content.id}" - if len(post_title) > 80: - post_title = post_title[:77] + "..." + if len(post_title) > 200: + post_title = post_title[:197] + "..." # Calculate actual template length with placeholders filled # Format template with dummy values to measure actual length @@ -353,11 +365,11 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None ) template_overhead = len(template_with_dummies) - # Calculate max image_prompt length: 1000 - template_overhead - safety margin (20) - max_image_prompt_length = 1000 - template_overhead - 20 - if max_image_prompt_length < 50: - # If template is too long, use minimum 50 chars for image_prompt - max_image_prompt_length = 50 + # Calculate max image_prompt length: max_prompt_length - template_overhead - safety margin (50) + max_image_prompt_length = max_prompt_length - template_overhead - 50 + if max_image_prompt_length < 100: + # If template is too long, use minimum 100 chars for image_prompt + max_image_prompt_length = 100 logger.warning(f"Template is very long ({template_overhead} chars), limiting image_prompt to {max_image_prompt_length}") logger.info(f"[process_image_generation_queue] Template overhead: {template_overhead} chars, max image_prompt: {max_image_prompt_length} chars") @@ -379,47 +391,48 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None image_prompt=image_prompt ) - # CRITICAL: Final safety check - ALWAYS truncate to 1000 chars max - if len(formatted_prompt) > 1000: - logger.warning(f"Formatted prompt too long ({len(formatted_prompt)} chars), truncating to 1000") + # CRITICAL: Final safety check - truncate to model-specific limit + if len(formatted_prompt) > max_prompt_length: + logger.warning(f"Formatted prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length} for {model}") # Try word-aware truncation - truncated = formatted_prompt[:997] + truncated = formatted_prompt[:max_prompt_length - 3] last_space = truncated.rfind(' ') - if last_space > 900: # Only use word-aware if we have a reasonable space + if last_space > max_prompt_length * 0.9: # Only use word-aware if we have a reasonable space formatted_prompt = truncated[:last_space] + "..." else: - formatted_prompt = formatted_prompt[:1000] # Hard truncate + formatted_prompt = formatted_prompt[:max_prompt_length] # Hard truncate - # Double-check after truncation - MUST be <= 1000 - if len(formatted_prompt) > 1000: - logger.error(f"Prompt still too long after truncation ({len(formatted_prompt)} chars), forcing hard truncate") - formatted_prompt = formatted_prompt[:1000] + # Double-check after truncation - MUST be <= max_prompt_length + if len(formatted_prompt) > max_prompt_length: + logger.error(f"Prompt still too long after truncation ({len(formatted_prompt)} chars), forcing hard truncate to {max_prompt_length}") + formatted_prompt = formatted_prompt[:max_prompt_length] except Exception as e: # Fallback if template formatting fails logger.warning(f"Prompt template formatting failed: {e}, using image prompt directly") formatted_prompt = image_prompt - # CRITICAL: Truncate to 1000 chars even in fallback - if len(formatted_prompt) > 1000: - logger.warning(f"Fallback prompt too long ({len(formatted_prompt)} chars), truncating to 1000") + # CRITICAL: Truncate to model-specific limit even in fallback + if len(formatted_prompt) > max_prompt_length: + logger.warning(f"Fallback prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length} for {model}") # Try word-aware truncation - truncated = formatted_prompt[:997] + truncated = formatted_prompt[:max_prompt_length - 3] last_space = truncated.rfind(' ') - if last_space > 900: + if last_space > max_prompt_length * 0.9: formatted_prompt = truncated[:last_space] + "..." else: - formatted_prompt = formatted_prompt[:1000] # Hard truncate - # Final hard truncate if still too long - MUST be <= 1000 - if len(formatted_prompt) > 1000: - formatted_prompt = formatted_prompt[:1000] + formatted_prompt = formatted_prompt[:max_prompt_length] # Hard truncate + # Final hard truncate if still too long - MUST be <= max_prompt_length + if len(formatted_prompt) > max_prompt_length: + logger.error(f"Fallback prompt still too long ({len(formatted_prompt)} chars), forcing hard truncate to {max_prompt_length}") + formatted_prompt = formatted_prompt[:max_prompt_length] # Generate image (using same approach as test image generation) logger.info(f"[process_image_generation_queue] Generating image {index}/{total_images} (ID: {image_id})") logger.info(f"[process_image_generation_queue] Provider: {provider}, Model: {model}") - logger.info(f"[process_image_generation_queue] Prompt length: {len(formatted_prompt)} (MUST be <= 1000)") - if len(formatted_prompt) > 1000: - logger.error(f"[process_image_generation_queue] ERROR: Prompt is {len(formatted_prompt)} chars, truncating NOW!") - formatted_prompt = formatted_prompt[:1000] + logger.info(f"[process_image_generation_queue] Prompt length: {len(formatted_prompt)} (MUST be <= {max_prompt_length} for {model})") + if len(formatted_prompt) > max_prompt_length: + logger.error(f"[process_image_generation_queue] ERROR: Prompt is {len(formatted_prompt)} chars, truncating NOW to {max_prompt_length}!") + formatted_prompt = formatted_prompt[:max_prompt_length] logger.info(f"[process_image_generation_queue] Final prompt length: {len(formatted_prompt)}") logger.info(f"[process_image_generation_queue] Image type: {image_type}") diff --git a/backend/igny8_core/modules/writer/migrations/0008_change_image_url_to_charfield.py b/backend/igny8_core/modules/writer/migrations/0008_change_image_url_to_charfield.py new file mode 100644 index 00000000..0852e759 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0008_change_image_url_to_charfield.py @@ -0,0 +1,19 @@ +# Generated migration to change image_url from URLField to CharField + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0007_add_content_to_images'), + ] + + operations = [ + migrations.AlterField( + model_name='images', + name='image_url', + field=models.CharField(blank=True, help_text='URL of the generated/stored image', max_length=500, null=True), + ), + ] + diff --git a/backend/igny8_core/utils/ai_processor.py b/backend/igny8_core/utils/ai_processor.py index 0befc71e..d4abee3d 100644 --- a/backend/igny8_core/utils/ai_processor.py +++ b/backend/igny8_core/utils/ai_processor.py @@ -843,6 +843,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi # Runware uses array payload with authentication and imageInference tasks # Reference: image-generation.php lines 79-97 import uuid + logger.info(f"[AIProcessor.generate_image] Runware API key check: has_key={bool(api_key)}, key_length={len(api_key) if api_key else 0}, key_preview={api_key[:10] + '...' + api_key[-4:] if api_key and len(api_key) > 14 else 'N/A'}") payload = [ { 'taskType': 'authentication',