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
541 lines
21 KiB
Python
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
|