Files
ALwrity/backend/services/linkedin/image_generation/linkedin_image_generator.py
ajaysi 63a0df2536 feat: LinkedIn LLM alignment - Phase 1-3 complete
Phase 1: Dead Code Cleanup
- Remove GeminiGroundedProvider import and property from linkedin_service.py
- Remove fallback_provider property (gemini_provider imports)
- Fix routers/linkedin.py edit endpoint to use llm_text_gen
- Delete dead LinkedInImageEditor class
- Remove dead _transform_gemini_sources from content_generator.py

Phase 2: Research Infrastructure Alignment
- Add user_id to _conduct_research() for pre-flight validation
- Add validate_exa_research_operations() before Exa/Tavily calls
- Pass user_id to provider.simple_search() for usage tracking
- Inject research content into LLM prompts via _build_research_context()
- Fix Google engine path to fallback to Exa
- Add Exa → Tavily fallback on research failure

Phase 3: Cosmetic Cleanup
- Rename _generate_prompts_with_gemini → _generate_prompts_with_llm
- Rename _build_gemini_prompt → _build_image_prompt
- Rename _parse_gemini_response → _parse_llm_response
- Remove all Gemini references from LinkedIn code (0 remaining)
- Update docstrings and log messages

Additional:
- Research caching using existing ResearchCache
- Shared ExaContentResearchProvider in services/research/
- Persona service uses llm_text_gen instead of gemini_structured_json_response
- LinkedInWriter.tsx ChatMessage → ChatMsg type mapping fix
- RegisterLinkedInActionsEnhanced.tsx content_format_rules typing fix
2026-06-12 18:58:53 +05:30

541 lines
21 KiB
Python

"""
LinkedIn Image Generator Service
This service generates LinkedIn-optimized images using the common
llm_providers infrastructure. It provides professional, business-appropriate
imagery for LinkedIn content.
"""
import os
import asyncio
import logging
from datetime import datetime
from typing import Dict, Any, Optional, Tuple
from pathlib import Path
from PIL import Image
from io import BytesIO
# Import existing infrastructure
from ...onboarding.api_key_manager import APIKeyManager
from ...llm_providers.main_image_generation import generate_image
from ...llm_providers.main_image_editing import edit_image as common_edit_image
# Set up logging
logger = logging.getLogger(__name__)
class LinkedInImageGenerator:
"""
Handles LinkedIn-optimized image generation using common infrastructure.
This service integrates with the llm_providers image generation system
and provides LinkedIn-specific image optimization, quality assurance,
and professional business aesthetics.
"""
def __init__(self, api_key_manager: Optional[APIKeyManager] = None):
"""
Initialize the LinkedIn Image Generator.
Args:
api_key_manager: API key manager for authentication
"""
self.api_key_manager = api_key_manager or APIKeyManager()
self.default_aspect_ratio = "1:1" # LinkedIn post optimal ratio
self.max_retries = 3
# LinkedIn-specific image requirements
self.min_resolution = (1024, 1024)
self.max_file_size_mb = 5
self.supported_formats = ["PNG", "JPEG"]
logger.info("LinkedIn Image Generator initialized")
async def generate_image(
self,
prompt: str,
content_context: Dict[str, Any],
aspect_ratio: str = "1:1",
style_preference: str = "professional",
user_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Generate LinkedIn-optimized image using AI provider.
Args:
prompt: User's image generation prompt
content_context: LinkedIn content context (topic, industry, content_type)
aspect_ratio: Image aspect ratio (1:1, 16:9, 4:3, 1.91:1, 1:1.25)
style_preference: Style preference (professional, creative, industry-specific)
user_id: User ID for tenant provider resolution
Returns:
Dict containing generation result, image data, and metadata
"""
try:
start_time = datetime.now()
logger.info(f"Starting LinkedIn image generation for topic: {content_context.get('topic', 'Unknown')}")
# Enhance prompt with LinkedIn-specific context
enhanced_prompt = self._enhance_prompt_for_linkedin(
prompt, content_context, style_preference, aspect_ratio
)
# Generate image using tenant-aware provider selection
generation_result = await self._generate_with_provider(enhanced_prompt, aspect_ratio, user_id)
if not generation_result.get('success'):
return {
'success': False,
'error': generation_result.get('error', 'Image generation failed'),
'generation_time': (datetime.now() - start_time).total_seconds()
}
# Process and validate generated image
processed_image = await self._process_generated_image(
generation_result['image_data'],
content_context,
aspect_ratio
)
generation_time = (datetime.now() - start_time).total_seconds()
return {
'success': True,
'image_data': processed_image['image_data'],
'image_url': processed_image.get('image_url'),
'metadata': {
'prompt_used': enhanced_prompt,
'original_prompt': prompt,
'style_preference': style_preference,
'aspect_ratio': aspect_ratio,
'content_context': content_context,
'generation_time': generation_time,
'model_used': generation_result.get('model'),
'image_format': processed_image['format'],
'image_size': processed_image['size'],
'resolution': processed_image['resolution']
},
'linkedin_optimization': {
'mobile_optimized': True,
'professional_aesthetic': True,
'brand_compliant': True,
'engagement_optimized': True
}
}
except Exception as e:
logger.error(f"Error in LinkedIn image generation: {str(e)}")
return {
'success': False,
'error': f"Image generation failed: {str(e)}",
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
}
async def edit_image(
self,
input_image_bytes: bytes,
edit_prompt: str,
content_context: Dict[str, Any],
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Edit existing image using unified image editing infrastructure.
Args:
input_image_bytes: Input image bytes to edit
edit_prompt: Description of desired edits
content_context: LinkedIn content context for optimization
user_id: User ID for tenant provider resolution and subscription checks
Returns:
Dict containing edited image result and metadata
"""
try:
start_time = datetime.now()
logger.info(f"Starting LinkedIn image editing with prompt: {edit_prompt[:100]}...")
# Enhance edit prompt for LinkedIn optimization
enhanced_edit_prompt = self._enhance_edit_prompt_for_linkedin(
edit_prompt, content_context
)
# Use unified image editing system.
# common_edit_image() handles: provider resolution, pre-flight validation,
# generation, and usage tracking — all via user_id.
result = common_edit_image(
input_image_bytes=input_image_bytes,
prompt=enhanced_edit_prompt,
user_id=user_id,
)
if result and result.image_bytes:
generation_time = (datetime.now() - start_time).total_seconds()
logger.info(
"LinkedIn image edited successfully via provider=%s model=%s in %.2fs",
result.provider, result.model, generation_time,
)
return {
'success': True,
'image_data': result.image_bytes,
'image_url': None, # not using URL-based retrieval
'width': result.width,
'height': result.height,
'provider': result.provider,
'model': result.model,
'metadata': {
'original_prompt': edit_prompt,
'enhanced_prompt': enhanced_edit_prompt,
'generation_time': generation_time,
'content_context': content_context,
},
}
else:
logger.warning("LinkedIn image editing returned no result")
return {
'success': False,
'error': 'Image editing returned no result',
'generation_time': (datetime.now() - start_time).total_seconds(),
}
except Exception as e:
logger.error(f"Error in LinkedIn image editing: {str(e)}", exc_info=True)
return {
'success': False,
'error': f"Image editing failed: {str(e)}",
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
}
def _enhance_prompt_for_linkedin(
self,
prompt: str,
content_context: Dict[str, Any],
style_preference: str,
aspect_ratio: str
) -> str:
"""
Enhance user prompt with LinkedIn-specific context and best practices.
Args:
prompt: Original user prompt
content_context: LinkedIn content context
style_preference: Preferred visual style
aspect_ratio: Image aspect ratio
Returns:
Enhanced prompt optimized for LinkedIn
"""
topic = content_context.get('topic', 'business')
industry = content_context.get('industry', 'business')
content_type = content_context.get('content_type', 'post')
# Base LinkedIn optimization
linkedin_optimizations = [
f"Create a professional LinkedIn {content_type} image for {topic}",
f"Industry: {industry}",
f"Professional business aesthetic suitable for LinkedIn audience",
f"Mobile-optimized design for LinkedIn feed viewing",
f"Aspect ratio: {aspect_ratio}",
"High-quality, modern design with clear visual hierarchy",
"Professional color scheme and typography",
"Suitable for business and professional networking"
]
# Style-specific enhancements
if style_preference == "professional":
style_enhancements = [
"Corporate aesthetics with clean lines and geometric shapes",
"Professional color palette (blues, grays, whites)",
"Modern business environment or abstract business concepts",
"Clean, minimalist design approach"
]
elif style_preference == "creative":
style_enhancements = [
"Eye-catching and engaging visual style",
"Vibrant colors while maintaining professional appeal",
"Creative composition that encourages social media engagement",
"Modern design elements with business context"
]
else: # industry-specific
style_enhancements = [
f"Industry-specific visual elements for {industry}",
"Professional yet creative approach",
"Balanced design suitable for business audience",
"Industry-relevant imagery and color schemes"
]
# Combine all enhancements
enhanced_prompt = f"{prompt}\n\n"
enhanced_prompt += "\n".join(linkedin_optimizations)
enhanced_prompt += "\n" + "\n".join(style_enhancements)
logger.info(f"Enhanced prompt for LinkedIn: {enhanced_prompt[:200]}...")
return enhanced_prompt
def _enhance_edit_prompt_for_linkedin(
self,
edit_prompt: str,
content_context: Dict[str, Any]
) -> str:
"""
Enhance edit prompt for LinkedIn optimization.
Args:
edit_prompt: Original edit prompt
content_context: LinkedIn content context
Returns:
Enhanced edit prompt
"""
industry = content_context.get('industry', 'business')
linkedin_edit_enhancements = [
f"Maintain professional business aesthetic for {industry} industry",
"Ensure mobile-optimized composition for LinkedIn feed",
"Keep professional color scheme and typography",
"Maintain brand consistency and visual hierarchy"
]
enhanced_edit_prompt = f"{edit_prompt}\n\n"
enhanced_edit_prompt += "\n".join(linkedin_edit_enhancements)
return enhanced_edit_prompt
async def _generate_with_provider(self, prompt: str, aspect_ratio: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
Generate image using unified image generation infrastructure.
Provider resolution, pre-flight validation, and usage tracking
are all handled by generate_image() from main_image_generation.
Args:
prompt: Enhanced prompt for image generation
aspect_ratio: Desired aspect ratio
user_id: User ID for tenant provider resolution and subscription checks
Returns:
Generation result from image generation provider
"""
try:
# Map aspect ratio to dimensions (LinkedIn-optimized)
aspect_map = {
"1:1": (1024, 1024),
"16:9": (1920, 1080),
"4:3": (1366, 1024),
"9:16": (1080, 1920),
"1.91:1": (1200, 627), # LinkedIn recommended landscape
"1:1.25": (1080, 1350), # LinkedIn recommended portrait
}
width, height = aspect_map.get(aspect_ratio, (1024, 1024))
# Delegate to unified image generation system.
# Generate_image() handles: provider resolution, pre-flight validation,
# model auto-detection, generation, and usage tracking.
# We do NOT pass explicit provider or model — let generate_image() resolve
# them from tenant config and user defaults.
result = generate_image(
prompt=prompt,
options={
"width": width,
"height": height,
},
user_id=user_id
)
if result and result.image_bytes:
return {
'success': True,
'image_data': result.image_bytes,
'image_path': None,
'width': result.width,
'height': result.height,
'provider': result.provider,
'model': result.model,
}
else:
return {
'success': False,
'error': 'Image generation returned no result'
}
except Exception as e:
logger.error(f"Error in image generation: {str(e)}")
return {
'success': False,
'error': f"Image generation failed: {str(e)}"
}
async def _process_generated_image(
self,
image_data: bytes,
content_context: Dict[str, Any],
aspect_ratio: str
) -> Dict[str, Any]:
"""
Process and validate generated image for LinkedIn use.
Args:
image_data: Raw image data
content_context: LinkedIn content context
aspect_ratio: Image aspect ratio
Returns:
Processed image information
"""
try:
# Open image for processing
image = Image.open(BytesIO(image_data))
# Get image information
width, height = image.size
format_name = image.format or "PNG"
# Validate resolution
if width < self.min_resolution[0] or height < self.min_resolution[1]:
logger.warning(f"Generated image resolution {width}x{height} below minimum {self.min_resolution}")
# Validate file size
image_size_mb = len(image_data) / (1024 * 1024)
if image_size_mb > self.max_file_size_mb:
logger.warning(f"Generated image size {image_size_mb:.2f}MB exceeds maximum {self.max_file_size_mb}MB")
# LinkedIn-specific optimizations
optimized_image = self._optimize_for_linkedin(image, content_context)
# Convert back to bytes
output_buffer = BytesIO()
optimized_image.save(output_buffer, format=format_name, optimize=True)
optimized_data = output_buffer.getvalue()
return {
'image_data': optimized_data,
'format': format_name,
'size': len(optimized_data),
'resolution': (width, height),
'aspect_ratio': f"{width}:{height}"
}
except Exception as e:
logger.error(f"Error processing generated image: {str(e)}")
# Return original image data if processing fails
return {
'image_data': image_data,
'format': 'PNG',
'size': len(image_data),
'resolution': (1024, 1024),
'aspect_ratio': aspect_ratio
}
def _optimize_for_linkedin(self, image: Image.Image, content_context: Dict[str, Any]) -> Image.Image:
"""
Optimize image specifically for LinkedIn display.
Args:
image: PIL Image object
content_context: LinkedIn content context
Returns:
Optimized image
"""
try:
# Ensure minimum resolution
width, height = image.size
if width < self.min_resolution[0] or height < self.min_resolution[1]:
# Resize to minimum resolution while maintaining aspect ratio
ratio = max(self.min_resolution[0] / width, self.min_resolution[1] / height)
new_width = int(width * ratio)
new_height = int(height * ratio)
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
logger.info(f"Resized image to {new_width}x{new_height} for LinkedIn optimization")
# Convert to RGB if necessary (for JPEG compatibility)
if image.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
return image
except Exception as e:
logger.error(f"Error optimizing image for LinkedIn: {str(e)}")
return image # Return original if optimization fails
async def validate_image_for_linkedin(self, image_data: bytes) -> Dict[str, Any]:
"""
Validate image for LinkedIn compliance and quality standards.
Args:
image_data: Image data to validate
Returns:
Validation results
"""
try:
image = Image.open(BytesIO(image_data))
width, height = image.size
validation_results = {
'resolution_ok': width >= self.min_resolution[0] and height >= self.min_resolution[1],
'aspect_ratio_suitable': self._is_aspect_ratio_suitable(width, height),
'file_size_ok': len(image_data) <= self.max_file_size_mb * 1024 * 1024,
'format_supported': image.format in self.supported_formats,
'professional_aesthetic': True, # Placeholder for future AI-based validation
'overall_score': 0
}
# Calculate overall score
score = 0
if validation_results['resolution_ok']: score += 25
if validation_results['aspect_ratio_suitable']: score += 25
if validation_results['file_size_ok']: score += 20
if validation_results['format_supported']: score += 20
if validation_results['professional_aesthetic']: score += 10
validation_results['overall_score'] = score
return validation_results
except Exception as e:
logger.error(f"Error validating image: {str(e)}")
return {
'resolution_ok': False,
'aspect_ratio_suitable': False,
'file_size_ok': False,
'format_supported': False,
'professional_aesthetic': False,
'overall_score': 0,
'error': str(e)
}
def _is_aspect_ratio_suitable(self, width: int, height: int) -> bool:
"""
Check if image aspect ratio is suitable for LinkedIn.
Args:
width: Image width
height: Image height
Returns:
True if aspect ratio is suitable for LinkedIn
"""
ratio = width / height
# LinkedIn-optimized aspect ratios
suitable_ratios = [
(0.9, 1.1), # 1:1 (square)
(1.6, 1.8), # 16:9 (landscape)
(0.7, 0.8), # 4:3 (portrait)
(1.2, 1.4), # 5:4 (landscape)
(1.85, 2.0), # 1.91:1 (LinkedIn recommended landscape)
(0.6, 0.72), # 1:1.25 (LinkedIn recommended portrait, ~0.8)
(0.65, 0.85), # 1:1.25 broader match
]
for min_ratio, max_ratio in suitable_ratios:
if min_ratio <= ratio <= max_ratio:
return True
return False