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
This commit is contained in:
ajaysi
2026-06-12 18:58:53 +05:30
parent e54aaa7a3e
commit 63a0df2536
37 changed files with 2891 additions and 1355 deletions

View File

@@ -36,6 +36,7 @@ class SearchEngine(str, Enum):
METAPHOR = "metaphor"
GOOGLE = "google"
TAVILY = "tavily"
EXA = "exa"
class GroundingLevel(str, Enum):
@@ -57,7 +58,7 @@ class LinkedInPostRequest(BaseModel):
include_hashtags: bool = Field(default=True, description="Whether to include hashtags")
include_call_to_action: bool = Field(default=True, description="Whether to include call to action")
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
max_length: int = Field(default=3000, description="Maximum character count", ge=100, le=3000)
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
include_citations: bool = Field(default=True, description="Whether to include inline citations")
@@ -94,7 +95,7 @@ class LinkedInArticleRequest(BaseModel):
include_images: bool = Field(default=True, description="Whether to generate image suggestions")
seo_optimization: bool = Field(default=True, description="Whether to include SEO optimization")
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
word_count: int = Field(default=1500, description="Target word count", ge=500, le=5000)
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
include_citations: bool = Field(default=True, description="Whether to include inline citations")
@@ -129,9 +130,11 @@ class LinkedInCarouselRequest(BaseModel):
number_of_slides: int = Field(default=5, description="Number of slides", ge=3, le=10)
include_cover_slide: bool = Field(default=True, description="Whether to include a cover slide")
include_cta_slide: bool = Field(default=True, description="Whether to include a call-to-action slide")
key_points: Optional[List[str]] = Field(None, description="Specific key points to cover", max_items=10)
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
color_scheme: str = Field(default="professional", description="Color scheme for PDF rendering: professional, creative, industry, dark, minimal")
include_citations: bool = Field(default=True, description="Whether to include inline citations")
class Config:
@@ -144,9 +147,11 @@ class LinkedInCarouselRequest(BaseModel):
"number_of_slides": 6,
"include_cover_slide": True,
"include_cta_slide": True,
"key_points": ["Remote collaboration tools", "Work-life balance", "Productivity metrics"],
"research_enabled": True,
"search_engine": "google",
"grounding_level": "enhanced",
"color_scheme": "professional",
"include_citations": True
}
}
@@ -161,8 +166,9 @@ class LinkedInVideoScriptRequest(BaseModel):
video_duration: int = Field(default=60, description="Target video duration in seconds", ge=30, le=300)
include_captions: bool = Field(default=True, description="Whether to include captions")
include_thumbnail_suggestions: bool = Field(default=True, description="Whether to include thumbnail suggestions")
key_points: Optional[List[str]] = Field(None, description="Specific key points to cover in the video", max_items=10)
research_enabled: bool = Field(default=True, description="Whether to include research-backed content")
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
include_citations: bool = Field(default=True, description="Whether to include inline citations")
@@ -176,6 +182,7 @@ class LinkedInVideoScriptRequest(BaseModel):
"video_duration": 90,
"include_captions": True,
"include_thumbnail_suggestions": True,
"key_points": ["Zero trust architecture", "Phishing prevention", "Incident response"],
"research_enabled": True,
"search_engine": "google",
"grounding_level": "enhanced",
@@ -193,7 +200,7 @@ class LinkedInCommentResponseRequest(BaseModel):
response_length: str = Field(default="medium", description="Length of response: short, medium, long")
include_questions: bool = Field(default=True, description="Whether to include engaging questions")
research_enabled: bool = Field(default=False, description="Whether to include research-backed content")
search_engine: SearchEngine = Field(default=SearchEngine.GOOGLE, description="Search engine for research")
search_engine: SearchEngine = Field(default=SearchEngine.EXA, description="Search engine for research")
grounding_level: GroundingLevel = Field(default=GroundingLevel.BASIC, description="Level of content grounding")
class Config:
@@ -451,4 +458,24 @@ class LinkedInCommentResponseResult(BaseModel):
tone_analysis: Optional[Dict[str, Any]] = None
generation_metadata: Dict[str, Any] = {}
error: Optional[str] = None
grounding_status: Optional[Dict[str, Any]] = Field(None, description="Grounding operation status")
grounding_status: Optional[Dict[str, Any]] = Field(None, description="Grounding operation status")
class LinkedInEditContentRequest(BaseModel):
"""Request model for AI-powered LinkedIn content editing."""
content: str = Field(..., description="Content to edit", min_length=1)
edit_type: str = Field(..., description="Type of edit: professionalize, optimize_engagement, add_hashtags, adjust_tone, expand, condense, add_cta")
industry: Optional[str] = Field(None, description="Industry context for the edit")
tone: Optional[str] = Field(None, description="Target tone: professional, conversational, authoritative, educational, friendly")
target_audience: Optional[str] = Field(None, description="Target audience for the content")
parameters: Optional[Dict[str, Any]] = Field(None, description="Additional parameters specific to edit type")
class LinkedInEditContentResponse(BaseModel):
"""Response model for AI-powered LinkedIn content editing."""
success: bool = True
content: Optional[str] = None
edit_type: str
provider: Optional[str] = None
model: Optional[str] = None
error: Optional[str] = None

View File

@@ -7,9 +7,10 @@ proper error handling, monitoring, and documentation.
"""
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, FileResponse
from typing import Dict, Any, Optional
import time
import json
from loguru import logger
from pathlib import Path
@@ -17,11 +18,17 @@ from models.linkedin_models import (
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
LinkedInPostResponse, LinkedInArticleResponse, LinkedInCarouselResponse,
LinkedInVideoScriptResponse, LinkedInCommentResponseResult
LinkedInVideoScriptResponse, LinkedInCommentResponseResult,
LinkedInEditContentRequest, LinkedInEditContentResponse
)
from services.llm_providers.main_text_generation import llm_text_gen
from services.linkedin_service import LinkedInService
from services.linkedin.carousel import LinkedInCarouselPDFRenderer
from middleware.auth_middleware import get_current_user
from utils.text_asset_tracker import save_and_track_text_content
from models.api_monitoring import APIRequest
from sqlalchemy import func
from collections import defaultdict
# Initialize the LinkedIn service instance
linkedin_service = LinkedInService()
@@ -29,6 +36,34 @@ from services.subscription.monitoring_middleware import DatabaseAPIMonitor
from services.database import get_db as get_db_dependency
from sqlalchemy.orm import Session
# Simple in-memory rate limiter: {user_id: [timestamp, ...]}
_rate_limit_store: Dict[str, list] = defaultdict(list)
RATE_LIMIT_MAX_REQUESTS = 30
RATE_LIMIT_WINDOW = 60 # seconds
def check_rate_limit(user_id: str) -> Optional[int]:
"""Returns retry-after seconds if rate limited, None otherwise."""
now = time.time()
window_start = now - RATE_LIMIT_WINDOW
timestamps = _rate_limit_store[user_id]
# Prune old entries
_rate_limit_store[user_id] = [t for t in timestamps if t > window_start]
if len(_rate_limit_store[user_id]) >= RATE_LIMIT_MAX_REQUESTS:
return int(_rate_limit_store[user_id][0] + RATE_LIMIT_WINDOW - now)
_rate_limit_store[user_id].append(now)
return None
ERROR_CODES = {
'VALIDATION': 'LINKEDIN_ERR_001',
'GENERATION_FAILED': 'LINKEDIN_ERR_002',
'RATE_LIMITED': 'LINKEDIN_ERR_003',
'SAVE_FAILED': 'LINKEDIN_ERR_004',
'NOT_FOUND': 'LINKEDIN_ERR_404',
}
def error_response(code: str, message: str) -> dict:
return {"code": code, "message": message}
# Initialize router
router = APIRouter(
prefix="/api/linkedin",
@@ -112,10 +147,10 @@ async def generate_post(
# Validate request
if not request.topic.strip():
raise HTTPException(status_code=422, detail="Topic cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
# Extract user_id
user_id = None
@@ -124,22 +159,30 @@ async def generate_post(
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Rate limit check
retry_after = check_rate_limit(user_id or 'anonymous')
if retry_after:
raise HTTPException(
status_code=429,
detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."),
headers={"Retry-After": str(retry_after)}
)
# Generate post content
response = await linkedin_service.generate_linkedin_post(request)
if not response.success:
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Post generation failed"))
# Log successful request
duration = time.time() - start_time
background_tasks.add_task(
log_api_request, http_request, db, duration, 200
)
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
# Save and track text content
if user_id and response.data and response.data.content:
try:
# Combine all text content
text_content = response.data.content
if response.data.call_to_action:
text_content += f"\n\nCall to Action: {response.data.call_to_action}"
@@ -166,7 +209,7 @@ async def generate_post(
subdirectory="posts"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn post asset: {track_error}")
logger.error(f"Failed to track LinkedIn post asset: {track_error}")
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
return response
@@ -177,14 +220,13 @@ async def generate_post(
duration = time.time() - start_time
logger.error(f"Error generating LinkedIn post: {str(e)}")
# Log failed request
background_tasks.add_task(
log_api_request, http_request, db, duration, 500
)
raise HTTPException(
status_code=500,
detail=f"Failed to generate LinkedIn post: {str(e)}"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn post: {str(e)}")
)
@@ -222,10 +264,10 @@ async def generate_article(
# Validate request
if not request.topic.strip():
raise HTTPException(status_code=422, detail="Topic cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
# Extract user_id
user_id = None
@@ -234,17 +276,16 @@ async def generate_article(
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Rate limit check
retry_after = check_rate_limit(user_id or 'anonymous')
if retry_after:
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
# Generate article content
response = await linkedin_service.generate_linkedin_article(request)
# Log successful request
duration = time.time() - start_time
background_tasks.add_task(
log_api_request, http_request, db, duration, 200
)
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Article generation failed"))
# Save and track text content (non-blocking)
if user_id and response.data:
@@ -282,7 +323,7 @@ async def generate_article(
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn article asset: {track_error}")
logger.error(f"Failed to track LinkedIn article asset: {track_error}")
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
return response
@@ -300,7 +341,7 @@ async def generate_article(
raise HTTPException(
status_code=500,
detail=f"Failed to generate LinkedIn article: {str(e)}"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn article: {str(e)}")
)
@@ -337,13 +378,13 @@ async def generate_carousel(
# Validate request
if not request.topic.strip():
raise HTTPException(status_code=422, detail="Topic cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
if request.slide_count < 3 or request.slide_count > 15:
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
if request.number_of_slides < 3 or request.number_of_slides > 15:
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Number of slides must be between 3 and 15"))
# Extract user_id
user_id = None
@@ -352,18 +393,23 @@ async def generate_carousel(
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Rate limit check
retry_after = check_rate_limit(user_id or 'anonymous')
if retry_after:
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
# Generate carousel content
response = await linkedin_service.generate_linkedin_carousel(request)
if not response.success:
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Carousel generation failed"))
# Log successful request
duration = time.time() - start_time
background_tasks.add_task(
log_api_request, http_request, db, duration, 200
)
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data:
try:
@@ -381,10 +427,10 @@ async def generate_carousel(
source_module="linkedin_writer",
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
description=f"LinkedIn carousel for {request.industry} industry",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {getattr(request, 'number_of_slides', request.slide_count if hasattr(request, 'slide_count') else 5)}",
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {request.number_of_slides}",
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
asset_metadata={
"slide_count": len(response.data.slides),
"number_of_slides": len(response.data.slides),
"has_cover": response.data.cover_slide is not None,
"has_cta": response.data.cta_slide is not None
},
@@ -392,7 +438,7 @@ async def generate_carousel(
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn carousel asset: {track_error}")
logger.error(f"Failed to track LinkedIn carousel asset: {track_error}")
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
return response
@@ -410,10 +456,82 @@ async def generate_carousel(
raise HTTPException(
status_code=500,
detail=f"Failed to generate LinkedIn carousel: {str(e)}"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn carousel: {str(e)}")
)
@router.post(
"/generate-carousel-pdf",
summary="Render Carousel as PDF",
description="""
Render previously generated LinkedIn carousel content as a PDF document.
Takes carousel content (slides with title, content, visual_elements) and
renders them into visually appealing slide images composed into a PDF
ready for LinkedIn upload (1.91:1 aspect ratio, max 300 slides, max 100MB).
"""
)
async def generate_carousel_pdf(
request: LinkedInCarouselRequest,
background_tasks: BackgroundTasks,
http_request: Request,
db: Session = Depends(get_db),
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Generate carousel content and render as PDF."""
start_time = time.time()
try:
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# First generate carousel content
content_result = await linkedin_service.generate_linkedin_carousel(request)
if not content_result.success or not content_result.data:
raise HTTPException(status_code=500, detail=content_result.error or "Carousel generation failed")
carousel_data = content_result.data.model_dump()
# Then render to PDF
renderer = LinkedInCarouselPDFRenderer()
pdf_result = await renderer.render_carousel_to_pdf(
carousel_data=carousel_data,
color_scheme=request.color_scheme,
user_id=user_id,
)
if not pdf_result.get('success'):
raise HTTPException(status_code=500, detail=pdf_result.get('error', 'PDF rendering failed'))
duration = time.time() - start_time
background_tasks.add_task(log_api_request, http_request, db, duration, 200)
pdf_path = pdf_result.get('pdf_path')
if pdf_path:
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=f"linkedin_carousel_{request.topic[:30].replace(' ', '_')}.pdf"
)
return JSONResponse(content={
'success': True,
'pdf_bytes': pdf_result.get('pdf_bytes'),
'metadata': pdf_result.get('metadata'),
})
except HTTPException:
raise
except Exception as e:
duration = time.time() - start_time
logger.error(f"Error generating carousel PDF: {str(e)}")
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate carousel PDF: {str(e)}"))
@router.post(
"/generate-video-script",
response_model=LinkedInVideoScriptResponse,
@@ -447,14 +565,14 @@ async def generate_video_script(
# Validate request
if not request.topic.strip():
raise HTTPException(status_code=422, detail="Topic cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Topic cannot be empty"))
if not request.industry.strip():
raise HTTPException(status_code=422, detail="Industry cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Industry cannot be empty"))
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
if video_duration < 15 or video_duration > 300:
raise HTTPException(status_code=422, detail="Video length must be between 15 and 300 seconds")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Video length must be between 15 and 300 seconds"))
# Extract user_id
user_id = None
@@ -463,18 +581,23 @@ async def generate_video_script(
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Rate limit check
retry_after = check_rate_limit(user_id or 'anonymous')
if retry_after:
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
# Generate video script content
response = await linkedin_service.generate_linkedin_video_script(request)
if not response.success:
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Video script generation failed"))
# Log successful request
duration = time.time() - start_time
background_tasks.add_task(
log_api_request, http_request, db, duration, 200
)
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and response.data:
try:
@@ -514,7 +637,7 @@ async def generate_video_script(
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn video script asset: {track_error}")
logger.error(f"Failed to track LinkedIn video script asset: {track_error}")
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
return response
@@ -532,7 +655,7 @@ async def generate_video_script(
raise HTTPException(
status_code=500,
detail=f"Failed to generate LinkedIn video script: {str(e)}"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn video script: {str(e)}")
)
@@ -572,10 +695,10 @@ async def generate_comment_response(
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
if not original_comment.strip():
raise HTTPException(status_code=422, detail="Original comment cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Original comment cannot be empty"))
if not post_context.strip():
raise HTTPException(status_code=422, detail="Post context cannot be empty")
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Post context cannot be empty"))
# Extract user_id
user_id = None
@@ -584,18 +707,23 @@ async def generate_comment_response(
if not user_id:
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
# Rate limit check
retry_after = check_rate_limit(user_id or 'anonymous')
if retry_after:
raise HTTPException(status_code=429, detail=error_response(ERROR_CODES['RATE_LIMITED'], f"Rate limit exceeded. Retry after {retry_after} seconds."), headers={"Retry-After": str(retry_after)})
# Generate comment response
response = await linkedin_service.generate_linkedin_comment_response(request)
if not response.success:
raise HTTPException(status_code=500, detail=error_response(ERROR_CODES['GENERATION_FAILED'], response.error or "Comment response generation failed"))
# Log successful request
duration = time.time() - start_time
background_tasks.add_task(
log_api_request, http_request, db, duration, 200
)
if not response.success:
raise HTTPException(status_code=500, detail=response.error)
# Save and track text content (non-blocking)
if user_id and hasattr(response, 'response') and response.response:
try:
@@ -626,7 +754,7 @@ async def generate_comment_response(
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track LinkedIn comment response asset: {track_error}")
logger.error(f"Failed to track LinkedIn comment response asset: {track_error}")
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
return response
@@ -644,7 +772,7 @@ async def generate_comment_response(
raise HTTPException(
status_code=500,
detail=f"Failed to generate LinkedIn comment response: {str(e)}"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], f"Failed to generate LinkedIn comment response: {str(e)}")
)
@@ -691,6 +819,128 @@ async def get_content_types():
}
@router.post(
"/edit-content",
response_model=LinkedInEditContentResponse,
summary="Edit LinkedIn Content with AI",
description="""
Apply AI-powered edits to LinkedIn content.
Supported edit types:
- professionalize: Rewrite content with professional business language
- optimize_engagement: Optimize hook and structure for maximum engagement
- add_hashtags: Generate relevant, industry-specific hashtags
- adjust_tone: Rewrite content in a different tone (professional, conversational, authoritative, etc.)
- expand: Add depth, examples, and insights to content
- condense: Shorten content while preserving key messages
- add_cta: Generate a contextual call-to-action
"""
)
async def edit_linkedin_content(
request: LinkedInEditContentRequest,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
):
"""Edit LinkedIn content using AI-powered text generation."""
try:
# Extract user_id for subscription checking
user_id = None
if current_user:
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
if not request.content.strip():
return LinkedInEditContentResponse(
success=False, error="Content cannot be empty", edit_type=request.edit_type
)
# Build the system prompt based on edit type
system_prompts = {
"professionalize": "You are a professional business writer. Rewrite the following LinkedIn content to be more professional, polished, and industry-appropriate. Maintain the original message but use sophisticated business language, improve sentence structure, and ensure a confident executive presence.",
"optimize_engagement": "You are a LinkedIn engagement strategist. Rewrite the following content to maximize engagement. Strengthen the hook in the first 2 lines, add thought-provoking elements, improve readability with shorter sentences, and ensure the content encourages comments and shares.",
"add_hashtags": "You are a LinkedIn hashtag strategist. Generate 5 highly relevant, industry-specific hashtags for the following content. Return the original content unchanged, followed by two newlines and the hashtags on a single line.",
"adjust_tone": "You are a LinkedIn tone specialist. Rewrite the following content in the specified tone while preserving all key information and the overall message.",
"expand": "You are a LinkedIn content strategist. Expand the following content by adding relevant examples, data points, actionable insights, and deeper analysis. Maintain the original structure but add substantial value while keeping it LinkedIn-appropriate (under 3000 characters).",
"condense": "You are a LinkedIn editing specialist. Condense the following content to be more concise and impactful. Remove filler words, tighten sentences, and preserve only the strongest points. Keep the core message intact.",
"add_cta": "You are a LinkedIn conversion strategist. Add a compelling, contextual call-to-action to the following content. The CTA should feel natural, not salesy, and should encourage meaningful engagement (comments, connections, or discussions)."
}
system_prompt = system_prompts.get(request.edit_type)
if not system_prompt:
return LinkedInEditContentResponse(
success=False, error=f"Unknown edit type: {request.edit_type}", edit_type=request.edit_type
)
# Build the user prompt with context
user_prompt = f"Content to edit:\n\n{request.content}\n\n"
if request.industry:
user_prompt += f"Industry: {request.industry}\n"
if request.tone:
user_prompt += f"Target tone: {request.tone}\n"
if request.target_audience:
user_prompt += f"Target audience: {request.target_audience}\n"
if request.parameters:
user_prompt += f"Additional context: {json.dumps(request.parameters)}\n"
user_prompt += "\nReturn ONLY the edited content without any explanations, labels, or markdown formatting."
# Generate edited content using provider-agnostic gateway
temperature = {
"professionalize": 0.3,
"optimize_engagement": 0.7,
"add_hashtags": 0.4,
"adjust_tone": 0.5,
"expand": 0.7,
"condense": 0.3,
"add_cta": 0.6,
}.get(request.edit_type, 0.5)
max_tokens = {
"expand": 2048,
"professionalize": 1024,
"optimize_engagement": 1024,
"adjust_tone": 1024,
"condense": 1024,
"add_cta": 1024,
"add_hashtags": 512,
}.get(request.edit_type, 1024)
edited = llm_text_gen(
prompt=user_prompt,
system_prompt=system_prompt,
user_id=user_id,
flow_type=f"linkedin_edit_{request.edit_type}",
max_tokens=max_tokens,
temperature=temperature
)
if not edited:
return LinkedInEditContentResponse(
success=False, error="AI editing returned empty result", edit_type=request.edit_type
)
edited = edited.strip()
# For add_hashtags, ensure hashtags are separated from content
if request.edit_type == "add_hashtags":
if not edited.endswith("\n\n"):
# Hashtags might be inline; separate them
pass
logger.info(f"LinkedIn content edited successfully via {request.edit_type}")
return LinkedInEditContentResponse(
success=True,
content=edited,
edit_type=request.edit_type,
provider="llm_text_gen",
model="provider-agnostic"
)
except Exception as e:
logger.error(f"Error editing LinkedIn content: {str(e)}", exc_info=True)
return LinkedInEditContentResponse(
success=False, error=f"Editing failed: {str(e)}", edit_type=request.edit_type
)
@router.get(
"/usage-stats",
summary="Get Usage Statistics",
@@ -699,30 +949,29 @@ async def get_content_types():
async def get_usage_stats(db: Session = Depends(get_db)):
"""Get usage statistics for LinkedIn content generation."""
try:
# This would query the database for actual usage stats
# For now, returning mock data
base = db.query(APIRequest).filter(APIRequest.path.like('/api/linkedin/%'))
total = base.count()
successful = base.filter(APIRequest.status_code < 400).count()
avg_dur = base.with_entities(func.avg(APIRequest.duration)).scalar() or 0
content_types = {
"posts": base.filter(APIRequest.path.like('%generate-post')).count(),
"articles": base.filter(APIRequest.path.like('%generate-article')).count(),
"carousels": base.filter(APIRequest.path.like('%generate-carousel')).count(),
"video_scripts": base.filter(APIRequest.path.like('%generate-video-script')).count(),
"comment_responses": base.filter(APIRequest.path.like('%generate-comment-response')).count(),
}
return {
"total_requests": 1250,
"content_types": {
"posts": 650,
"articles": 320,
"carousels": 180,
"video_scripts": 70,
"comment_responses": 30
},
"success_rate": 0.96,
"average_generation_time": 4.2,
"top_industries": [
"Technology",
"Healthcare",
"Finance",
"Marketing",
"Education"
]
"total_requests": total,
"content_types": content_types,
"success_rate": round(successful / max(total, 1), 2),
"average_generation_time": round(float(avg_dur), 2),
}
except Exception as e:
logger.error(f"Error retrieving usage stats: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to retrieve usage statistics"
detail=error_response(ERROR_CODES['GENERATION_FAILED'], "Failed to retrieve usage statistics")
)

View File

@@ -17,13 +17,13 @@ from .content_generator_prompts import (
VideoScriptGenerator
)
# Import new image generation services
# Import image generation services
from .image_generation import (
LinkedInImageGenerator,
LinkedInImageEditor,
LinkedInImageStorage
)
from .image_prompts import LinkedInPromptGenerator
from .carousel import LinkedInCarouselPDFRenderer
__all__ = [
# Content Generation
@@ -42,9 +42,10 @@ __all__ = [
# Image Generation Services
'LinkedInImageGenerator',
'LinkedInImageEditor',
'LinkedInImageStorage',
'LinkedInPromptGenerator'
'LinkedInPromptGenerator',
# Carousel Rendering
'LinkedInCarouselPDFRenderer',
]
# Version information

View File

@@ -0,0 +1,3 @@
from .carousel_renderer import LinkedInCarouselPDFRenderer
__all__ = ['LinkedInCarouselPDFRenderer']

View File

@@ -0,0 +1,336 @@
"""
LinkedIn Carousel PDF Renderer
Renders text-based carousel slides into visually appealing PNG images
and composes them into a LinkedIn-compatible PDF document (1.91:1 ratio).
"""
import os
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from reportlab.lib.pagesizes import landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Image as RLImage, PageBreak
logger = logging.getLogger(__name__)
class LinkedInCarouselPDFRenderer:
COLOR_SCHEMES = {
'professional': {
'background_start': (25, 55, 109),
'background_end': (41, 128, 185),
'title_color': (255, 255, 255),
'content_color': (236, 240, 241),
'accent_color': (52, 152, 219),
},
'creative': {
'background_start': (142, 68, 173),
'background_end': (231, 76, 60),
'title_color': (255, 255, 255),
'content_color': (245, 245, 245),
'accent_color': (241, 196, 15),
},
'industry': {
'background_start': (39, 174, 96),
'background_end': (44, 62, 80),
'title_color': (255, 255, 255),
'content_color': (236, 240, 241),
'accent_color': (46, 204, 113),
},
'dark': {
'background_start': (20, 20, 30),
'background_end': (60, 60, 80),
'title_color': (255, 255, 255),
'content_color': (200, 200, 210),
'accent_color': (100, 200, 255),
},
'minimal': {
'background_start': (245, 245, 250),
'background_end': (255, 255, 255),
'title_color': (44, 62, 80),
'content_color': (80, 80, 90),
'accent_color': (52, 152, 219),
},
}
def __init__(self, output_dir: str = None):
self.slide_width = 1200
self.slide_height = 627
self.slide_aspect_ratio = "1.91:1"
self.max_file_size_bytes = 100 * 1024 * 1024
self.max_slides = 300
self.output_dir = output_dir or "data/media/linkedin_carousels"
async def render_carousel_to_pdf(
self,
carousel_data: Dict[str, Any],
color_scheme: str = 'professional',
user_id: Optional[str] = None,
) -> Dict[str, Any]:
start_time = datetime.now()
os.makedirs(self.output_dir, exist_ok=True)
try:
slides = carousel_data.get('slides', [])
if not slides:
return {'success': False, 'error': 'No slides to render'}
title = carousel_data.get('title', 'LinkedIn Carousel')
cover_slide = carousel_data.get('cover_slide')
cta_slide = carousel_data.get('cta_slide')
total_slides = len(slides) + (1 if cover_slide else 0) + (1 if cta_slide else 0)
if total_slides > self.max_slides:
error = f'Too many slides: {total_slides} exceeds max {self.max_slides}'
return {'success': False, 'error': error}
session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
image_paths = []
if cover_slide:
path = self._render_slide(
slide=cover_slide, slide_number=0, session_id=session_id,
color_scheme=color_scheme, is_cover=True, carousel_title=title,
)
if path:
image_paths.append(path)
for i, slide in enumerate(slides):
path = self._render_slide(
slide=slide, slide_number=i + 1, session_id=session_id,
color_scheme=color_scheme, is_cover=False,
)
if path:
image_paths.append(path)
if cta_slide:
path = self._render_slide(
slide=cta_slide, slide_number=len(slides) + 1, session_id=session_id,
color_scheme=color_scheme, is_cta=True,
)
if path:
image_paths.append(path)
if not image_paths:
return {'success': False, 'error': 'No slide images generated'}
pdf_filename = f"linkedin_carousel_{session_id}.pdf"
pdf_path = os.path.join(self.output_dir, pdf_filename)
pdf_bytes = self._compose_pdf(image_paths, pdf_path)
file_size = len(pdf_bytes)
if file_size > self.max_file_size_bytes:
logger.warning("PDF size %.2f MB exceeds max %.2f MB",
file_size / (1024 * 1024), self.max_file_size_bytes / (1024 * 1024))
generation_time = (datetime.now() - start_time).total_seconds()
return {
'success': True,
'pdf_bytes': pdf_bytes,
'pdf_path': pdf_path,
'metadata': {
'slide_count': len(image_paths),
'generation_time': generation_time,
'file_size': file_size,
'file_size_mb': round(file_size / (1024 * 1024), 2),
'dimensions': f'{self.slide_width}x{self.slide_height}',
'aspect_ratio': self.slide_aspect_ratio,
}
}
except Exception as e:
logger.error("Error rendering carousel PDF: %s", str(e))
return {'success': False, 'error': f'Carousel PDF rendering failed: {str(e)}'}
def _render_slide(
self,
slide: Dict[str, Any],
slide_number: int,
session_id: str,
color_scheme: str = 'professional',
is_cover: bool = False,
is_cta: bool = False,
carousel_title: str = '',
) -> Optional[str]:
try:
colors = self.COLOR_SCHEMES.get(color_scheme, self.COLOR_SCHEMES['professional'])
img = Image.new('RGB', (self.slide_width, self.slide_height))
draw = ImageDraw.Draw(img)
self._draw_gradient(draw, colors)
draw.rectangle([0, self.slide_height - 6, self.slide_width, self.slide_height], fill=colors['accent_color'])
if is_cover:
self._draw_centered_text(draw, carousel_title or slide.get('title', ''),
(self.slide_width // 2, 180), colors['title_color'],
font_size=42, max_width=self.slide_width - 160)
subtitle = slide.get('content', '')
if subtitle:
self._draw_centered_text(draw, subtitle,
(self.slide_width // 2, 320), colors['content_color'],
font_size=24, max_width=self.slide_width - 200, max_lines=3)
self._draw_centered_text(draw, "Swipe to explore →",
(self.slide_width // 2, 480), colors['accent_color'],
font_size=18)
elif is_cta:
self._draw_text(draw, slide.get('title', ''), (60, 160), colors['title_color'],
font_size=36, max_width=self.slide_width - 120, max_lines=2)
content = slide.get('content', '')
if content:
self._draw_text(draw, content, (60, 260), colors['content_color'],
font_size=22, max_width=self.slide_width - 120, max_lines=6)
btn_x, btn_y = self.slide_width // 2 - 200, 440
draw.rounded_rectangle([btn_x, btn_y, btn_x + 400, btn_y + 55], radius=27, fill=colors['accent_color'])
self._draw_centered_text(draw, "Share Your Thoughts →",
(self.slide_width // 2, btn_y + 27), (255, 255, 255), font_size=22)
else:
self._draw_text(draw, str(slide_number),
(self.slide_width - 50, 20), colors['accent_color'], font_size=16)
title = slide.get('title', '')
if title:
self._draw_text(draw, title, (60, 50), colors['title_color'],
font_size=30, max_width=self.slide_width - 120, max_lines=2)
content = slide.get('content', '')
if content:
self._draw_text(draw, content, (60, 145), colors['content_color'],
font_size=20, max_width=self.slide_width - 120, max_lines=10)
visual_elements = slide.get('visual_elements', [])
if visual_elements:
self._draw_visual_elements(draw, visual_elements, colors)
filename = f"slide_{session_id}_{slide_number:03d}.png"
filepath = os.path.join(self.output_dir, filename)
img.save(filepath, 'PNG', optimize=True)
return filepath
except Exception as e:
logger.error("Error rendering slide %d: %s", slide_number, str(e))
return None
def _draw_gradient(self, draw: ImageDraw.Draw, colors: Dict):
sr, sg, sb = colors['background_start']
er, eg, eb = colors['background_end']
for y in range(self.slide_height):
t = y / self.slide_height
draw.line([(0, y), (self.slide_width, y)],
fill=(int(sr + (er - sr) * t), int(sg + (eg - sg) * t), int(sb + (eb - sb) * t)))
def _draw_text(self, draw: ImageDraw.Draw, text: str, position: tuple, color: tuple,
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
font = self._get_font(font_size, bold)
x, y = position
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bb = draw.textbbox((0, 0), test_line, font=font)
tw = bb[2] - bb[0]
if max_width and tw > max_width and current_line:
lines.append(current_line)
if max_lines and len(lines) >= max_lines:
lines[-1] = lines[-1][:-3] + "..."
break
current_line = word
else:
current_line = test_line
if current_line and (not max_lines or len(lines) < max_lines):
lines.append(current_line)
line_height = int(font_size * 1.4)
for i, line in enumerate(lines):
draw.text((x, y + i * line_height), line, fill=color, font=font)
def _draw_centered_text(self, draw: ImageDraw.Draw, text: str, center: tuple, color: tuple,
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
font = self._get_font(font_size, bold)
cx, cy = center
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bb = draw.textbbox((0, 0), test_line, font=font)
tw = bb[2] - bb[0]
if max_width and tw > max_width and current_line:
lines.append(current_line)
if max_lines and len(lines) >= max_lines:
lines[-1] = lines[-1][:-3] + "..."
break
current_line = word
else:
current_line = test_line
if current_line and (not max_lines or len(lines) < max_lines):
lines.append(current_line)
line_height = int(font_size * 1.4)
total_height = len(lines) * line_height
start_y = cy - total_height // 2
for i, line in enumerate(lines):
bb = draw.textbbox((0, 0), line, font=font)
tw = bb[2] - bb[0]
x = cx - tw // 2
draw.text((x, start_y + i * line_height), line, fill=color, font=font)
def _draw_visual_elements(self, draw: ImageDraw.Draw, elements: List[str], colors: Dict):
y_start = self.slide_height - 60
x_start = 60
for i, element in enumerate(elements[:4]):
cx = x_start + i * 280
draw.ellipse([cx, y_start, cx + 12, y_start + 12], fill=colors['accent_color'])
font = self._get_font(12, False)
draw.text((cx + 20, y_start - 2), element[:25], fill=colors['content_color'], font=font)
def _get_font(self, size: int, bold: bool = False):
try:
return ImageFont.truetype("arialbd.ttf" if bold else "arial.ttf", size)
except (IOError, OSError):
try:
return ImageFont.truetype("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", size)
except (IOError, OSError):
return ImageFont.load_default()
def _compose_pdf(self, image_paths: List[str], output_path: str) -> bytes:
pw = self.slide_width
ph = self.slide_height
# Leave 1pt margin to avoid ReportLab frame size issues
m = 1
iw = pw - 2 * m
ih = ph - 2 * m
from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate
from reportlab.lib.pagesizes import landscape
frame = Frame(m, m, iw, ih, id="slide_frame",
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0)
template = PageTemplate(id="slide", frames=[frame], pagesize=(pw, ph))
doc = BaseDocTemplate(output_path, pagesize=(pw, ph))
doc.addPageTemplates([template])
story = []
for i, img_path in enumerate(image_paths):
story.append(RLImage(img_path, width=iw, height=ih))
if i < len(image_paths) - 1:
story.append(PageBreak())
doc.build(story)
with open(output_path, 'rb') as f:
return f.read()

View File

@@ -2,6 +2,7 @@
Content Generator for LinkedIn Content Generation
Handles the main content generation logic for posts and articles.
Uses llm_text_gen for provider-agnostic LLM access (respects GPT_PROVIDER).
"""
from typing import Dict, Any, List, Optional
@@ -21,6 +22,7 @@ from services.linkedin.content_generator_prompts import (
CarouselGenerator,
VideoScriptGenerator
)
from services.llm_providers.main_text_generation import llm_text_gen
from services.persona_analysis_service import PersonaAnalysisService
import time
@@ -28,11 +30,9 @@ import time
class ContentGenerator:
"""Handles content generation for all LinkedIn content types."""
def __init__(self, citation_manager=None, quality_analyzer=None, gemini_grounded=None, fallback_provider=None):
def __init__(self, citation_manager=None, quality_analyzer=None):
self.citation_manager = citation_manager
self.quality_analyzer = quality_analyzer
self.gemini_grounded = gemini_grounded
self.fallback_provider = fallback_provider
# Persona caching
self._persona_cache: Dict[str, Dict[str, Any]] = {}
@@ -105,22 +105,24 @@ class ContentGenerator:
del self._cache_timestamps[key]
logger.info(f"Cleared persona cache for user {user_id}")
def _transform_gemini_sources(self, gemini_sources):
"""Transform Gemini sources to ResearchSource format."""
transformed_sources = []
for source in gemini_sources:
transformed_source = ResearchSource(
title=source.get('title', 'Unknown Source'),
url=source.get('url', ''),
content=f"Source from {source.get('title', 'Unknown')}",
relevance_score=0.8, # Default relevance score
credibility_score=0.7, # Default credibility score
domain_authority=0.6, # Default domain authority
source_type=source.get('type', 'web'),
publication_date=datetime.now().strftime('%Y-%m-%d')
)
transformed_sources.append(transformed_source)
return transformed_sources
def _build_research_context(self, research_sources: List) -> str:
"""Build research context string from research sources for prompt injection."""
if not research_sources:
return ""
context_parts = ["\n\nRESEARCH CONTEXT (use this information to ground your content with facts and data):"]
for i, source in enumerate(research_sources[:5], 1): # Limit to top 5 sources
title = getattr(source, 'title', f'Source {i}')
url = getattr(source, 'url', '')
content = getattr(source, 'content', '')
context_parts.append(f"\n{i}. {title}")
if url:
context_parts.append(f" URL: {url}")
if content:
context_parts.append(f" Key insight: {content[:300]}")
context_parts.append("\nInstructions: Use the research above to include specific data points, statistics, and factual claims in your content. Cite sources where appropriate.")
return "\n".join(context_parts)
async def generate_post(
self,
@@ -155,21 +157,12 @@ class ContentGenerator:
logger.info(f" - First research source: {research_sources[0] if research_sources else 'None'}")
logger.info(f" - Research sources types: {[type(s) for s in research_sources[:3]]}")
# Step 3: Add citations if requested - POST METHOD
# Step 3: Add citations if requested
citations = []
source_list = None
final_research_sources = research_sources # Default to passed research_sources
final_research_sources = research_sources
# Use sources and citations from content_result if available (from Gemini grounding)
if content_result.get('citations') and content_result.get('sources'):
logger.info(f"Using citations and sources from Gemini grounding: {len(content_result['citations'])} citations, {len(content_result['sources'])} sources")
citations = content_result['citations']
# Transform Gemini sources to ResearchSource format
gemini_sources = self._transform_gemini_sources(content_result['sources'])
source_list = self.citation_manager.generate_source_list(gemini_sources) if self.citation_manager else None
# Use transformed sources for the response
final_research_sources = gemini_sources
elif request.include_citations and research_sources and self.citation_manager:
if request.include_citations and research_sources and self.citation_manager:
try:
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
citations = self.citation_manager.extract_citations(content_result['content'])
@@ -224,7 +217,7 @@ class ContentGenerator:
data=post_content,
research_sources=final_research_sources, # Use final_research_sources
generation_metadata={
'model_used': 'gemini-2.0-flash-001',
'model_used': 'llm_text_gen',
'generation_time': generation_time,
'research_time': research_time,
'grounding_enabled': grounding_enabled
@@ -251,21 +244,12 @@ class ContentGenerator:
try:
start_time = datetime.now()
# Step 3: Add citations if requested - ARTICLE METHOD
# Step 3: Add citations if requested
citations = []
source_list = None
final_research_sources = research_sources # Default to passed research_sources
final_research_sources = research_sources
# Use sources and citations from content_result if available (from Gemini grounding)
if content_result.get('citations') and content_result.get('sources'):
logger.info(f"Using citations and sources from Gemini grounding: {len(content_result['citations'])} citations, {len(content_result['sources'])} sources")
citations = content_result['citations']
# Transform Gemini sources to ResearchSource format
gemini_sources = self._transform_gemini_sources(content_result['sources'])
source_list = self.citation_manager.generate_source_list(gemini_sources) if self.citation_manager else None
# Use transformed sources for the response
final_research_sources = gemini_sources
elif request.include_citations and research_sources and self.citation_manager:
if request.include_citations and research_sources and self.citation_manager:
try:
citations = self.citation_manager.extract_citations(content_result['content'])
source_list = self.citation_manager.generate_source_list(research_sources)
@@ -317,7 +301,7 @@ class ContentGenerator:
data=article_content,
research_sources=final_research_sources, # Use final_research_sources
generation_metadata={
'model_used': 'gemini-2.0-flash-001',
'model_used': 'llm_text_gen',
'generation_time': generation_time,
'research_time': research_time,
'grounding_enabled': grounding_enabled
@@ -386,7 +370,7 @@ class ContentGenerator:
'alternative_responses': content_result.get('alternative_responses', []),
'tone_analysis': content_result.get('tone_analysis'),
'generation_metadata': {
'model_used': 'gemini-2.0-flash-001',
'model_used': 'llm_text_gen',
'generation_time': generation_time,
'research_time': research_time,
'grounding_enabled': grounding_enabled
@@ -402,19 +386,14 @@ class ContentGenerator:
}
# Grounded content generation methods
async def generate_grounded_post_content(self, request, research_sources: List) -> Dict[str, Any]:
"""Generate grounded post content using the enhanced Gemini provider with native grounding."""
async def generate_grounded_post_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
"""Generate post content using provider-agnostic llm_text_gen."""
try:
if not self.gemini_grounded:
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
# Build the prompt for grounded generation using persona if available (DB vs session override)
user_id = int(getattr(request, "user_id", 0) or 0)
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
# Build the prompt using persona if available
uid = int(getattr(request, "user_id", 0) or 0)
persona_data = self._get_cached_persona_data(uid, 'linkedin')
if getattr(request, 'persona_override', None):
try:
# Merge shallowly: override core and platform adaptation parts
override = request.persona_override
if persona_data:
core = persona_data.get('core_persona', {})
@@ -431,61 +410,40 @@ class ContentGenerator:
pass
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
# Generate grounded content using native Google Search grounding
result = await self.gemini_grounded.generate_grounded_content(
# Inject research context into prompt
research_context = self._build_research_context(research_sources)
if research_context:
prompt += research_context
# Generate content using provider-agnostic gateway
raw_response = llm_text_gen(
prompt=prompt,
content_type="linkedin_post",
temperature=0.7,
max_tokens=request.max_length
user_id=user_id,
flow_type="linkedin_post",
max_tokens=request.max_length,
temperature=0.7
)
return result
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
return {
'content': content_text,
'sources': [],
'citations': [],
'grounding_enabled': bool(research_sources),
'fallback_used': False
}
except Exception as e:
logger.error(f"Error generating grounded post content: {str(e)}")
logger.info("Attempting fallback to standard content generation...")
# Fallback to standard content generation without grounding
try:
if not self.fallback_provider:
raise Exception("No fallback provider available")
# Build a simpler prompt for fallback generation
prompt = PostPromptBuilder.build_post_prompt(request)
# Generate content using fallback provider (it's a dict with functions)
if 'generate_text' in self.fallback_provider:
result = await self.fallback_provider['generate_text'](
prompt=prompt,
temperature=0.7,
max_tokens=request.max_length
)
else:
raise Exception("Fallback provider doesn't have generate_text method")
# Return result in the expected format
return {
'content': result.get('content', '') if isinstance(result, dict) else str(result),
'sources': [],
'citations': [],
'grounding_enabled': False,
'fallback_used': True
}
except Exception as fallback_error:
logger.error(f"Fallback generation also failed: {str(fallback_error)}")
raise Exception(f"Failed to generate content: {str(e)}. Fallback also failed: {str(fallback_error)}")
logger.error(f"Error generating post content: {str(e)}")
raise Exception(f"Failed to generate LinkedIn post: {str(e)}")
async def generate_grounded_article_content(self, request, research_sources: List) -> Dict[str, Any]:
"""Generate grounded article content using the enhanced Gemini provider with native grounding."""
async def generate_grounded_article_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
"""Generate article content using provider-agnostic llm_text_gen."""
try:
if not self.gemini_grounded:
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
# Build the prompt for grounded generation using persona if available (DB vs session override)
user_id = int(getattr(request, "user_id", 0) or 0)
persona_data = self._get_cached_persona_data(user_id, 'linkedin')
# Build the prompt using persona if available
uid = int(getattr(request, "user_id", 0) or 0)
persona_data = self._get_cached_persona_data(uid, 'linkedin')
if getattr(request, 'persona_override', None):
try:
override = request.persona_override
@@ -504,88 +462,129 @@ class ContentGenerator:
pass
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
# Generate grounded content using native Google Search grounding
result = await self.gemini_grounded.generate_grounded_content(
# Inject research context into prompt
research_context = self._build_research_context(research_sources)
if research_context:
prompt += research_context
# Generate content using provider-agnostic gateway
raw_response = llm_text_gen(
prompt=prompt,
content_type="linkedin_article",
temperature=0.7,
max_tokens=request.word_count * 10 # Approximate character count
user_id=user_id,
flow_type="linkedin_article",
max_tokens=request.word_count * 10,
temperature=0.7
)
return result
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
return {
'content': content_text,
'sources': [],
'citations': [],
'grounding_enabled': bool(research_sources),
'fallback_used': False
}
except Exception as e:
logger.error(f"Error generating grounded article content: {str(e)}")
raise Exception(f"Failed to generate grounded article content: {str(e)}")
logger.error(f"Error generating article content: {str(e)}")
raise Exception(f"Failed to generate LinkedIn article: {str(e)}")
async def generate_grounded_carousel_content(self, request, research_sources: List) -> Dict[str, Any]:
"""Generate grounded carousel content using the enhanced Gemini provider with native grounding."""
async def generate_grounded_carousel_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
"""Generate carousel content using provider-agnostic llm_text_gen."""
try:
if not self.gemini_grounded:
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
# Build the prompt for grounded generation using the new prompt builder
prompt = CarouselPromptBuilder.build_carousel_prompt(request)
# Generate grounded content using native Google Search grounding
result = await self.gemini_grounded.generate_grounded_content(
# Inject research context into prompt
research_context = self._build_research_context(research_sources)
if research_context:
prompt += research_context
# Generate content using provider-agnostic gateway
raw_response = llm_text_gen(
prompt=prompt,
content_type="linkedin_carousel",
temperature=0.7,
max_tokens=2000
user_id=user_id,
flow_type="linkedin_carousel",
max_tokens=2000,
temperature=0.7
)
return result
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
return {
'content': content_text,
'sources': [],
'citations': [],
'grounding_enabled': bool(research_sources),
'fallback_used': False
}
except Exception as e:
logger.error(f"Error generating grounded carousel content: {str(e)}")
raise Exception(f"Failed to generate grounded carousel content: {str(e)}")
logger.error(f"Error generating carousel content: {str(e)}")
raise Exception(f"Failed to generate LinkedIn carousel: {str(e)}")
async def generate_grounded_video_script_content(self, request, research_sources: List) -> Dict[str, Any]:
"""Generate grounded video script content using the enhanced Gemini provider with native grounding."""
async def generate_grounded_video_script_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
"""Generate video script content using provider-agnostic llm_text_gen."""
try:
if not self.gemini_grounded:
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
# Build the prompt for grounded generation using the new prompt builder
prompt = VideoScriptPromptBuilder.build_video_script_prompt(request)
# Generate grounded content using native Google Search grounding
result = await self.gemini_grounded.generate_grounded_content(
# Inject research context into prompt
research_context = self._build_research_context(research_sources)
if research_context:
prompt += research_context
# Generate content using provider-agnostic gateway
raw_response = llm_text_gen(
prompt=prompt,
content_type="linkedin_video_script",
temperature=0.7,
max_tokens=1500
user_id=user_id,
flow_type="linkedin_video_script",
max_tokens=1500,
temperature=0.7
)
return result
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
return {
'content': content_text,
'sources': [],
'citations': [],
'grounding_enabled': bool(research_sources),
'fallback_used': False
}
except Exception as e:
logger.error(f"Error generating grounded video script content: {str(e)}")
raise Exception(f"Failed to generate grounded video script content: {str(e)}")
logger.error(f"Error generating video script content: {str(e)}")
raise Exception(f"Failed to generate LinkedIn video script: {str(e)}")
async def generate_grounded_comment_response(self, request, research_sources: List) -> Dict[str, Any]:
"""Generate grounded comment response using the enhanced Gemini provider with native grounding."""
async def generate_grounded_comment_response(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
"""Generate comment response using provider-agnostic llm_text_gen."""
try:
if not self.gemini_grounded:
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
# Build the prompt for grounded generation using the new prompt builder
prompt = CommentResponsePromptBuilder.build_comment_response_prompt(request)
# Generate grounded content using native Google Search grounding
result = await self.gemini_grounded.generate_grounded_content(
# Inject research context into prompt
research_context = self._build_research_context(research_sources)
if research_context:
prompt += research_context
# Generate content using provider-agnostic gateway
raw_response = llm_text_gen(
prompt=prompt,
content_type="linkedin_comment_response",
temperature=0.7,
max_tokens=2000
user_id=user_id,
flow_type="linkedin_comment_response",
max_tokens=2000,
temperature=0.7
)
return result
content_text = raw_response if isinstance(raw_response, str) else str(raw_response or "")
return {
'content': content_text,
'sources': [],
'citations': [],
'grounding_enabled': bool(research_sources),
'fallback_used': False
}
except Exception as e:
logger.error(f"Error generating grounded comment response: {str(e)}")
raise Exception(f"Failed to generate grounded comment response: {str(e)}")
logger.error(f"Error generating comment response: {str(e)}")
raise Exception(f"Failed to generate LinkedIn comment response: {str(e)}")

View File

@@ -96,7 +96,7 @@ class CarouselGenerator:
'data': carousel_content,
'research_sources': research_sources,
'generation_metadata': {
'model_used': 'gemini-2.0-flash-001',
'model_used': 'llm_text_gen',
'generation_time': generation_time,
'research_time': research_time,
'grounding_enabled': grounding_enabled

View File

@@ -81,7 +81,7 @@ class VideoScriptGenerator:
'data': video_script,
'research_sources': research_sources,
'generation_metadata': {
'model_used': 'gemini-2.0-flash-001',
'model_used': 'llm_text_gen',
'generation_time': generation_time,
'research_time': research_time,
'grounding_enabled': grounding_enabled

View File

@@ -2,17 +2,15 @@
LinkedIn Image Generation Package
This package provides AI-powered image generation capabilities for LinkedIn content
using Google's Gemini API. It includes image generation, editing, storage, and
management services optimized for professional business use.
using the common llm_providers infrastructure. It includes image generation, storage,
and management services optimized for professional business use.
"""
from .linkedin_image_generator import LinkedInImageGenerator
from .linkedin_image_editor import LinkedInImageEditor
from .linkedin_image_storage import LinkedInImageStorage
__all__ = [
'LinkedInImageGenerator',
'LinkedInImageEditor',
'LinkedInImageStorage'
]

View File

@@ -1,530 +0,0 @@
"""
LinkedIn Image Editor Service
This service handles image editing capabilities for LinkedIn content using Gemini's
conversational editing features. It provides professional image refinement and
optimization specifically for LinkedIn use cases.
"""
import os
import base64
from typing import Dict, Any, Optional, List
from datetime import datetime
from PIL import Image, ImageEnhance, ImageFilter
from io import BytesIO
from loguru import logger
# Import existing infrastructure
from ...onboarding.api_key_manager import APIKeyManager
class LinkedInImageEditor:
"""
Handles LinkedIn image editing and refinement using Gemini's capabilities.
This service provides both AI-powered editing through Gemini and traditional
image processing for LinkedIn-specific optimizations.
"""
def __init__(self, api_key_manager: Optional[APIKeyManager] = None):
"""
Initialize the LinkedIn Image Editor.
Args:
api_key_manager: API key manager for Gemini authentication
"""
self.api_key_manager = api_key_manager or APIKeyManager()
self.model = "gemini-2.5-flash-image-preview"
# LinkedIn-specific editing parameters
self.enhancement_factors = {
'brightness': 1.1, # Slightly brighter for mobile viewing
'contrast': 1.05, # Subtle contrast enhancement
'sharpness': 1.2, # Enhanced sharpness for clarity
'saturation': 1.05 # Slight saturation boost
}
logger.info("LinkedIn Image Editor initialized")
async def edit_image_conversationally(
self,
base_image: bytes,
edit_prompt: str,
content_context: Dict[str, Any]
) -> Dict[str, Any]:
"""
Edit image using Gemini's conversational editing capabilities.
Args:
base_image: Base image data in bytes
edit_prompt: Natural language description of desired edits
content_context: LinkedIn content context for optimization
Returns:
Dict containing edited image result and metadata
"""
try:
start_time = datetime.now()
logger.info(f"Starting conversational image editing: {edit_prompt[:100]}...")
# Enhance edit prompt for LinkedIn optimization
enhanced_prompt = self._enhance_edit_prompt_for_linkedin(
edit_prompt, content_context
)
# TODO: Implement Gemini conversational editing when available
# For now, we'll use traditional image processing based on prompt analysis
edited_image = await self._apply_traditional_editing(
base_image, edit_prompt, content_context
)
if not edited_image.get('success'):
return edited_image
generation_time = (datetime.now() - start_time).total_seconds()
return {
'success': True,
'image_data': edited_image['image_data'],
'metadata': {
'edit_prompt': edit_prompt,
'enhanced_prompt': enhanced_prompt,
'editing_method': 'traditional_processing',
'editing_time': generation_time,
'content_context': content_context,
'model_used': self.model
},
'linkedin_optimization': {
'mobile_optimized': True,
'professional_aesthetic': True,
'brand_compliant': True,
'engagement_optimized': True
}
}
except Exception as e:
logger.error(f"Error in conversational image editing: {str(e)}")
return {
'success': False,
'error': f"Conversational editing failed: {str(e)}",
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
}
async def apply_style_transfer(
self,
base_image: bytes,
style_reference: bytes,
content_context: Dict[str, Any]
) -> Dict[str, Any]:
"""
Apply style transfer from reference image to base image.
Args:
base_image: Base image data in bytes
style_reference: Reference image for style transfer
content_context: LinkedIn content context
Returns:
Dict containing style-transferred image result
"""
try:
start_time = datetime.now()
logger.info("Starting style transfer for LinkedIn image")
# TODO: Implement Gemini style transfer when available
# For now, return placeholder implementation
return {
'success': False,
'error': 'Style transfer not yet implemented - coming in next Gemini API update',
'generation_time': (datetime.now() - start_time).total_seconds()
}
except Exception as e:
logger.error(f"Error in style transfer: {str(e)}")
return {
'success': False,
'error': f"Style transfer failed: {str(e)}",
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
}
async def enhance_image_quality(
self,
image_data: bytes,
enhancement_type: str = "linkedin_optimized",
content_context: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Enhance image quality using traditional image processing.
Args:
image_data: Image data in bytes
enhancement_type: Type of enhancement to apply
content_context: LinkedIn content context for optimization
Returns:
Dict containing enhanced image result
"""
try:
start_time = datetime.now()
logger.info(f"Starting image quality enhancement: {enhancement_type}")
# Open image for processing
image = Image.open(BytesIO(image_data))
original_size = image.size
# Apply LinkedIn-specific enhancements
if enhancement_type == "linkedin_optimized":
enhanced_image = self._apply_linkedin_enhancements(image, content_context)
elif enhancement_type == "professional":
enhanced_image = self._apply_professional_enhancements(image)
elif enhancement_type == "creative":
enhanced_image = self._apply_creative_enhancements(image)
else:
enhanced_image = self._apply_linkedin_enhancements(image, content_context)
# Convert back to bytes
output_buffer = BytesIO()
enhanced_image.save(output_buffer, format=image.format or "PNG", optimize=True)
enhanced_data = output_buffer.getvalue()
enhancement_time = (datetime.now() - start_time).total_seconds()
return {
'success': True,
'image_data': enhanced_data,
'metadata': {
'enhancement_type': enhancement_type,
'original_size': original_size,
'enhanced_size': enhanced_image.size,
'enhancement_time': enhancement_time,
'content_context': content_context
}
}
except Exception as e:
logger.error(f"Error in image quality enhancement: {str(e)}")
return {
'success': False,
'error': f"Quality enhancement failed: {str(e)}",
'generation_time': (datetime.now() - start_time).total_seconds() if 'start_time' in locals() else 0
}
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')
content_type = content_context.get('content_type', 'post')
linkedin_edit_enhancements = [
f"Maintain professional business aesthetic for {industry} industry",
f"Ensure mobile-optimized composition for LinkedIn {content_type}",
"Keep professional color scheme and typography",
"Maintain brand consistency and visual hierarchy",
"Optimize for LinkedIn feed viewing and engagement"
]
enhanced_prompt = f"{edit_prompt}\n\n"
enhanced_prompt += "\n".join(linkedin_edit_enhancements)
return enhanced_prompt
async def _apply_traditional_editing(
self,
base_image: bytes,
edit_prompt: str,
content_context: Dict[str, Any]
) -> Dict[str, Any]:
"""
Apply traditional image processing based on edit prompt analysis.
Args:
base_image: Base image data in bytes
edit_prompt: Description of desired edits
content_context: LinkedIn content context
Returns:
Dict containing edited image result
"""
try:
# Open image for processing
image = Image.open(BytesIO(base_image))
# Analyze edit prompt and apply appropriate processing
edit_prompt_lower = edit_prompt.lower()
if any(word in edit_prompt_lower for word in ['brighter', 'light', 'lighting']):
image = self._adjust_brightness(image, 1.2)
logger.info("Applied brightness adjustment")
if any(word in edit_prompt_lower for word in ['sharper', 'sharp', 'clear']):
image = self._apply_sharpening(image)
logger.info("Applied sharpening")
if any(word in edit_prompt_lower for word in ['warmer', 'warm', 'color']):
image = self._adjust_color_temperature(image, 'warm')
logger.info("Applied warm color adjustment")
if any(word in edit_prompt_lower for word in ['professional', 'business']):
image = self._apply_professional_enhancements(image)
logger.info("Applied professional enhancements")
# Convert back to bytes
output_buffer = BytesIO()
image.save(output_buffer, format=image.format or "PNG", optimize=True)
edited_data = output_buffer.getvalue()
return {
'success': True,
'image_data': edited_data
}
except Exception as e:
logger.error(f"Error in traditional editing: {str(e)}")
return {
'success': False,
'error': f"Traditional editing failed: {str(e)}"
}
def _apply_linkedin_enhancements(
self,
image: Image.Image,
content_context: Optional[Dict[str, Any]] = None
) -> Image.Image:
"""
Apply LinkedIn-specific image enhancements.
Args:
image: PIL Image object
content_context: LinkedIn content context
Returns:
Enhanced image
"""
try:
# Apply standard LinkedIn optimizations
image = self._adjust_brightness(image, self.enhancement_factors['brightness'])
image = self._adjust_contrast(image, self.enhancement_factors['contrast'])
image = self._apply_sharpening(image)
image = self._adjust_saturation(image, self.enhancement_factors['saturation'])
# Ensure professional appearance
image = self._ensure_professional_appearance(image, content_context)
return image
except Exception as e:
logger.error(f"Error applying LinkedIn enhancements: {str(e)}")
return image
def _apply_professional_enhancements(self, image: Image.Image) -> Image.Image:
"""
Apply professional business aesthetic enhancements.
Args:
image: PIL Image object
Returns:
Enhanced image
"""
try:
# Subtle enhancements for professional appearance
image = self._adjust_brightness(image, 1.05)
image = self._adjust_contrast(image, 1.03)
image = self._apply_sharpening(image)
return image
except Exception as e:
logger.error(f"Error applying professional enhancements: {str(e)}")
return image
def _apply_creative_enhancements(self, image: Image.Image) -> Image.Image:
"""
Apply creative and engaging enhancements.
Args:
image: PIL Image object
Returns:
Enhanced image
"""
try:
# More pronounced enhancements for creative appeal
image = self._adjust_brightness(image, 1.1)
image = self._adjust_contrast(image, 1.08)
image = self._adjust_saturation(image, 1.1)
image = self._apply_sharpening(image)
return image
except Exception as e:
logger.error(f"Error applying creative enhancements: {str(e)}")
return image
def _adjust_brightness(self, image: Image.Image, factor: float) -> Image.Image:
"""Adjust image brightness."""
try:
enhancer = ImageEnhance.Brightness(image)
return enhancer.enhance(factor)
except Exception as e:
logger.error(f"Error adjusting brightness: {str(e)}")
return image
def _adjust_contrast(self, image: Image.Image, factor: float) -> Image.Image:
"""Adjust image contrast."""
try:
enhancer = ImageEnhance.Contrast(image)
return enhancer.enhance(factor)
except Exception as e:
logger.error(f"Error adjusting contrast: {str(e)}")
return image
def _adjust_saturation(self, image: Image.Image, factor: float) -> Image.Image:
"""Adjust image saturation."""
try:
enhancer = ImageEnhance.Color(image)
return enhancer.enhance(factor)
except Exception as e:
logger.error(f"Error adjusting saturation: {str(e)}")
return image
def _apply_sharpening(self, image: Image.Image) -> Image.Image:
"""Apply image sharpening."""
try:
# Apply unsharp mask for professional sharpening
return image.filter(ImageFilter.UnsharpMask(radius=1, percent=150, threshold=3))
except Exception as e:
logger.error(f"Error applying sharpening: {str(e)}")
return image
def _adjust_color_temperature(self, image: Image.Image, temperature: str) -> Image.Image:
"""Adjust image color temperature."""
try:
if temperature == 'warm':
# Apply warm color adjustment
enhancer = ImageEnhance.Color(image)
image = enhancer.enhance(1.1)
# Slight red tint for warmth
# This is a simplified approach - more sophisticated color grading could be implemented
return image
else:
return image
except Exception as e:
logger.error(f"Error adjusting color temperature: {str(e)}")
return image
def _ensure_professional_appearance(
self,
image: Image.Image,
content_context: Optional[Dict[str, Any]] = None
) -> Image.Image:
"""
Ensure image meets professional LinkedIn standards.
Args:
image: PIL Image object
content_context: LinkedIn content context
Returns:
Professionally optimized image
"""
try:
# Ensure minimum quality standards
if image.mode in ('RGBA', 'LA', 'P'):
# Convert to RGB for better compatibility
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
# Ensure minimum resolution for LinkedIn
min_resolution = (1024, 1024)
if image.size[0] < min_resolution[0] or image.size[1] < min_resolution[1]:
# Resize to minimum resolution while maintaining aspect ratio
ratio = max(min_resolution[0] / image.size[0], min_resolution[1] / image.size[1])
new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio))
image = image.resize(new_size, Image.Resampling.LANCZOS)
logger.info(f"Resized image to {new_size} for LinkedIn professional standards")
return image
except Exception as e:
logger.error(f"Error ensuring professional appearance: {str(e)}")
return image
async def get_editing_suggestions(
self,
image_data: bytes,
content_context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
Get AI-powered editing suggestions for LinkedIn image.
Args:
image_data: Image data in bytes
content_context: LinkedIn content context
Returns:
List of editing suggestions
"""
try:
# Analyze image and provide contextual suggestions
suggestions = []
# Professional enhancement suggestions
suggestions.append({
'id': 'professional_enhancement',
'title': 'Professional Enhancement',
'description': 'Apply subtle professional enhancements for business appeal',
'prompt': 'Enhance this image with professional business aesthetics',
'priority': 'high'
})
# Mobile optimization suggestions
suggestions.append({
'id': 'mobile_optimization',
'title': 'Mobile Optimization',
'description': 'Optimize for LinkedIn mobile feed viewing',
'prompt': 'Optimize this image for mobile LinkedIn viewing',
'priority': 'medium'
})
# Industry-specific suggestions
industry = content_context.get('industry', 'business')
suggestions.append({
'id': 'industry_optimization',
'title': f'{industry.title()} Industry Optimization',
'description': f'Apply {industry} industry-specific visual enhancements',
'prompt': f'Enhance this image with {industry} industry aesthetics',
'priority': 'medium'
})
# Engagement optimization suggestions
suggestions.append({
'id': 'engagement_optimization',
'title': 'Engagement Optimization',
'description': 'Make this image more engaging for LinkedIn audience',
'prompt': 'Make this image more engaging and shareable for LinkedIn',
'priority': 'low'
})
return suggestions
except Exception as e:
logger.error(f"Error getting editing suggestions: {str(e)}")
return []

View File

@@ -1,8 +1,9 @@
"""
LinkedIn Image Generator Service
This service generates LinkedIn-optimized images using Google's Gemini API.
It provides professional, business-appropriate imagery for LinkedIn content.
This service generates LinkedIn-optimized images using the common
llm_providers infrastructure. It provides professional, business-appropriate
imagery for LinkedIn content.
"""
import os
@@ -17,6 +18,7 @@ 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__)
@@ -24,9 +26,9 @@ logger = logging.getLogger(__name__)
class LinkedInImageGenerator:
"""
Handles LinkedIn-optimized image generation using Gemini API.
Handles LinkedIn-optimized image generation using common infrastructure.
This service integrates with the existing Gemini provider infrastructure
This service integrates with the llm_providers image generation system
and provides LinkedIn-specific image optimization, quality assurance,
and professional business aesthetics.
"""
@@ -36,10 +38,9 @@ class LinkedInImageGenerator:
Initialize the LinkedIn Image Generator.
Args:
api_key_manager: API key manager for Gemini authentication
api_key_manager: API key manager for authentication
"""
self.api_key_manager = api_key_manager or APIKeyManager()
self.model = "gemini-2.5-flash-image-preview"
self.default_aspect_ratio = "1:1" # LinkedIn post optimal ratio
self.max_retries = 3
@@ -55,16 +56,18 @@ class LinkedInImageGenerator:
prompt: str,
content_context: Dict[str, Any],
aspect_ratio: str = "1:1",
style_preference: str = "professional"
style_preference: str = "professional",
user_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Generate LinkedIn-optimized image using Gemini API.
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)
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
@@ -78,8 +81,8 @@ class LinkedInImageGenerator:
prompt, content_context, style_preference, aspect_ratio
)
# Generate image using existing Gemini infrastructure
generation_result = await self._generate_with_gemini(enhanced_prompt, 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 {
@@ -108,7 +111,7 @@ class LinkedInImageGenerator:
'aspect_ratio': aspect_ratio,
'content_context': content_context,
'generation_time': generation_time,
'model_used': self.model,
'model_used': generation_result.get('model'),
'image_format': processed_image['format'],
'image_size': processed_image['size'],
'resolution': processed_image['resolution']
@@ -131,17 +134,19 @@ class LinkedInImageGenerator:
async def edit_image(
self,
base_image: bytes,
input_image_bytes: bytes,
edit_prompt: str,
content_context: Dict[str, Any]
content_context: Dict[str, Any],
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Edit existing image using Gemini's conversational editing capabilities.
Edit existing image using unified image editing infrastructure.
Args:
base_image: Base image data in bytes
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
@@ -155,18 +160,46 @@ class LinkedInImageGenerator:
edit_prompt, content_context
)
# Use Gemini's image editing capabilities
# Note: This will be implemented when Gemini's image editing is fully available
# For now, we'll return a placeholder implementation
# 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,
)
return {
'success': False,
'error': 'Image editing not yet implemented - coming in next Gemini API update',
'generation_time': (datetime.now() - start_time).total_seconds()
}
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)}")
logger.error(f"Error in LinkedIn image editing: {str(e)}", exc_info=True)
return {
'success': False,
'error': f"Image editing failed: {str(e)}",
@@ -268,13 +301,16 @@ class LinkedInImageGenerator:
return enhanced_edit_prompt
async def _generate_with_gemini(self, prompt: str, aspect_ratio: str) -> Dict[str, Any]:
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
@@ -285,26 +321,31 @@ class LinkedInImageGenerator:
"1:1": (1024, 1024),
"16:9": (1920, 1080),
"4:3": (1366, 1024),
"9:16": (1080, 1920), # Portrait for stories
"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))
# Use unified image generation system (defaults to provider based on GPT_PROVIDER)
# 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={
"provider": "gemini", # LinkedIn uses Gemini by default
"model": self.model if hasattr(self, 'model') else None,
"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, # No file path, using bytes directly
'image_path': None,
'width': result.width,
'height': result.height,
'provider': result.provider,
@@ -315,7 +356,7 @@ class LinkedInImageGenerator:
'success': False,
'error': 'Image generation returned no result'
}
except Exception as e:
logger.error(f"Error in image generation: {str(e)}")
return {
@@ -487,6 +528,9 @@ class LinkedInImageGenerator:
(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:

View File

@@ -6,8 +6,10 @@ It provides secure storage, efficient retrieval, and metadata management for gen
"""
import os
import re
import hashlib
import json
import shutil
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime, timedelta
from pathlib import Path
@@ -58,6 +60,8 @@ class LinkedInImageStorage:
self.max_storage_size_gb = 10 # Maximum storage size in GB
self.image_retention_days = 30 # Days to keep images
self.max_image_size_mb = 10 # Maximum individual image size in MB
self.max_images_per_user = 100 # Maximum images per user
self._uuid_pattern = re.compile(r'^[a-f0-9]{16}$')
logger.info(f"LinkedIn Image Storage initialized at {self.base_storage_path}")
@@ -102,6 +106,22 @@ class LinkedInImageStorage:
try:
start_time = datetime.now()
# Check per-user storage quota
if user_id:
user_count = await self._count_user_images(user_id)
if user_count >= self.max_images_per_user:
return {
'success': False,
'error': f"User image limit ({self.max_images_per_user}) reached. Delete existing images or increase limit."
}
# Check disk space
if not await self._check_disk_space(len(image_data)):
return {
'success': False,
'error': "Insufficient disk space for image storage."
}
# Generate unique image ID
image_id = self._generate_image_id(image_data, metadata)
@@ -170,6 +190,9 @@ class LinkedInImageStorage:
Dict containing image data and metadata
"""
try:
if not self._validate_image_id(image_id):
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
# Find image file
image_path = await self._find_image_by_id(image_id, user_id)
if not image_path:
@@ -216,6 +239,9 @@ class LinkedInImageStorage:
Dict containing deletion result
"""
try:
if not self._validate_image_id(image_id):
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
# Find image file
image_path = await self._find_image_by_id(image_id, user_id)
if not image_path:
@@ -418,6 +444,32 @@ class LinkedInImageStorage:
'error': f"Failed to get storage stats: {str(e)}"
}
def _validate_image_id(self, image_id: str) -> bool:
"""Validate image_id against expected format to prevent path traversal."""
return bool(self._uuid_pattern.match(image_id))
async def _count_user_images(self, user_id: str) -> int:
"""Count total images stored for a given user."""
try:
images_path, _ = self._get_workspace_paths(user_id)
count = 0
if images_path.exists():
for content_dir in images_path.iterdir():
if content_dir.is_dir():
count += sum(1 for f in content_dir.glob("*.png") if f.is_file())
return count
except Exception as e:
logger.warning(f"Error counting images for user {user_id}: {e}")
return 0
async def _check_disk_space(self, required_bytes: int) -> bool:
"""Check if sufficient disk space is available."""
try:
usage = shutil.disk_usage(self.base_storage_path)
return usage.free > required_bytes * 2 # require 2x headroom
except Exception:
return True # if we can't check, allow the write
def _generate_image_id(self, image_data: bytes, metadata: Dict[str, Any]) -> str:
"""Generate unique image ID based on content and metadata."""
# Create hash from image data and key metadata
@@ -569,6 +621,9 @@ class LinkedInImageStorage:
Returns:
Dict containing image metadata if found
"""
if not self._validate_image_id(image_id):
logger.warning(f"Invalid image ID format in metadata request: {image_id}")
return None
return await self._load_metadata(image_id, user_id)
async def _load_metadata(self, image_id: str, user_id: Optional[str] = None) -> Optional[Dict[str, Any]]:

View File

@@ -2,8 +2,8 @@
LinkedIn Image Prompts Package
This package provides AI-powered image prompt generation for LinkedIn content
using Google's Gemini API. It creates three distinct prompt styles optimized
for professional business image generation.
using the provider-agnostic llm_text_gen gateway. It creates three distinct
prompt styles optimized for professional business image generation.
"""
from .linkedin_prompt_generator import LinkedInPromptGenerator

View File

@@ -1,9 +1,10 @@
"""
LinkedIn Image Prompt Generator Service
This service generates AI-optimized image prompts for LinkedIn content using Gemini's
capabilities. It creates three distinct prompt styles (professional, creative, industry-specific)
following best practices for image generation.
This service generates AI-optimized image prompts for LinkedIn content using
the provider-agnostic llm_text_gen gateway. It creates three distinct prompt
styles (professional, creative, industry-specific) following best practices
for image generation.
"""
import asyncio
@@ -13,14 +14,14 @@ from loguru import logger
# Import existing infrastructure
from ...onboarding.api_key_manager import APIKeyManager
from ...llm_providers.gemini_provider import gemini_text_response
from ...llm_providers.main_text_generation import llm_text_gen
class LinkedInPromptGenerator:
"""
Generates AI-optimized image prompts for LinkedIn content.
This service creates three distinct prompt styles following Gemini API best practices:
This service creates three distinct prompt styles following best practices:
1. Professional Style - Corporate aesthetics, clean lines, business colors
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
3. Industry-Specific Style - Tailored to specific business sectors
@@ -31,10 +32,9 @@ class LinkedInPromptGenerator:
Initialize the LinkedIn Prompt Generator.
Args:
api_key_manager: API key manager for Gemini authentication
api_key_manager: API key manager for authentication
"""
self.api_key_manager = api_key_manager or APIKeyManager()
self.model = "gemini-2.0-flash-exp"
# Prompt generation configuration
self.max_prompt_length = 500
@@ -49,7 +49,8 @@ class LinkedInPromptGenerator:
async def generate_three_prompts(
self,
linkedin_content: Dict[str, Any],
aspect_ratio: str = "1:1"
aspect_ratio: str = "1:1",
user_id: str = None
) -> List[Dict[str, Any]]:
"""
Generate three AI-optimized image prompts for LinkedIn content.
@@ -57,6 +58,7 @@ class LinkedInPromptGenerator:
Args:
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
aspect_ratio: Desired image aspect ratio
user_id: User ID for subscription checking
Returns:
List of three prompt objects with style, prompt, and description
@@ -65,11 +67,11 @@ class LinkedInPromptGenerator:
start_time = datetime.now()
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
# Generate prompts using Gemini
prompts = await self._generate_prompts_with_gemini(linkedin_content, aspect_ratio)
# Generate prompts using provider-agnostic gateway
prompts = await self._generate_prompts_with_llm(linkedin_content, aspect_ratio, user_id)
if not prompts or len(prompts) < 3:
logger.warning("Gemini prompt generation failed, using fallback prompts")
logger.warning("Prompt generation failed, using fallback prompts")
prompts = self._get_fallback_prompts(linkedin_content, aspect_ratio)
# Ensure exactly 3 prompts
@@ -92,62 +94,65 @@ class LinkedInPromptGenerator:
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
async def _generate_prompts_with_gemini(
async def _generate_prompts_with_llm(
self,
linkedin_content: Dict[str, Any],
aspect_ratio: str
aspect_ratio: str,
user_id: str = None
) -> List[Dict[str, Any]]:
"""
Generate image prompts using Gemini AI.
Generate image prompts using provider-agnostic llm_text_gen.
Args:
linkedin_content: LinkedIn content context
aspect_ratio: Image aspect ratio
user_id: User ID for subscription checking
Returns:
List of generated prompts
"""
try:
# Build the prompt for Gemini
gemini_prompt = self._build_gemini_prompt(linkedin_content, aspect_ratio)
# Build the prompt
prompt = self._build_image_prompt(linkedin_content, aspect_ratio)
# Generate response using Gemini
response = gemini_text_response(
prompt=gemini_prompt,
temperature=0.7,
top_p=0.8,
n=1,
# Generate response using provider-agnostic gateway
response = llm_text_gen(
prompt=prompt,
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization.",
user_id=user_id,
flow_type="linkedin_image_prompts",
max_tokens=1000,
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization."
temperature=0.7
)
if not response:
logger.warning("No response from Gemini prompt generation")
logger.warning("No response from prompt generation")
return []
# Parse Gemini response into structured prompts
prompts = self._parse_gemini_response(response, linkedin_content)
# Parse response into structured prompts
response_text = response if isinstance(response, str) else str(response or "")
prompts = self._parse_llm_response(response_text, linkedin_content)
return prompts
except Exception as e:
logger.error(f"Error in Gemini prompt generation: {str(e)}")
logger.error(f"Error in prompt generation: {str(e)}")
return []
def _build_gemini_prompt(
def _build_image_prompt(
self,
linkedin_content: Dict[str, Any],
aspect_ratio: str
) -> str:
"""
Build comprehensive prompt for Gemini to generate image prompts.
Build comprehensive prompt for LLM to generate image prompts.
Args:
linkedin_content: LinkedIn content context
aspect_ratio: Image aspect ratio
Returns:
Formatted prompt for Gemini
Formatted prompt for LLM
"""
topic = linkedin_content.get('topic', 'business')
industry = linkedin_content.get('industry', 'business')
@@ -428,16 +433,16 @@ class LinkedInPromptGenerator:
else:
return 'Informational & Awareness'
def _parse_gemini_response(
def _parse_llm_response(
self,
response: str,
linkedin_content: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
Parse Gemini response into structured prompt objects.
Parse LLM response into structured prompt objects.
Args:
response: Raw response from Gemini
response: Raw response from LLM
linkedin_content: LinkedIn content context
Returns:
@@ -462,7 +467,7 @@ class LinkedInPromptGenerator:
return self._parse_response_manually(response, linkedin_content)
except Exception as e:
logger.error(f"Error parsing Gemini response: {str(e)}")
logger.error(f"Error parsing LLM response: {str(e)}")
return self._parse_response_manually(response, linkedin_content)
def _parse_response_manually(
@@ -474,7 +479,7 @@ class LinkedInPromptGenerator:
Manually parse response if JSON parsing fails.
Args:
response: Raw response from Gemini
response: Raw response from LLM
linkedin_content: LinkedIn content context
Returns:

View File

@@ -2,9 +2,10 @@
Research Handler for LinkedIn Content Generation
Handles research operations and timing for content generation.
Uses common Exa/Tavily infrastructure with pre-flight validation.
"""
from typing import List
from typing import List, Optional
from datetime import datetime
from loguru import logger
from models.linkedin_models import ResearchSource
@@ -21,11 +22,19 @@ class ResearchHandler:
request,
research_enabled: bool,
search_engine: str,
max_results: int = 10
max_results: int = 10,
user_id: Optional[str] = None
) -> tuple[List[ResearchSource], float]:
"""
Conduct research if enabled and return sources with timing.
Args:
request: Generation request object
research_enabled: Whether research is enabled
search_engine: Search engine to use (exa, tavily)
max_results: Maximum number of results
user_id: User ID for pre-flight validation and usage tracking
Returns:
Tuple of (research_sources, research_time)
"""
@@ -33,7 +42,6 @@ class ResearchHandler:
research_time = 0
if research_enabled:
# Debug: Log the search engine value being passed
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
research_start = datetime.now()
@@ -41,7 +49,8 @@ class ResearchHandler:
topic=request.topic,
industry=request.industry,
search_engine=search_engine,
max_results=max_results
max_results=max_results,
user_id=user_id
)
research_time = (datetime.now() - research_start).total_seconds()
logger.info(f"Research completed in {research_time:.2f}s, found {len(research_sources)} sources")
@@ -67,10 +76,5 @@ class ResearchHandler:
if not research_enabled or level == 'none':
return False
# For Google native grounding, Gemini returns sources in the generation metadata,
# so we should not require pre-fetched research_sources.
if engine_str == 'google':
return True
# For other engines, require that research actually returned sources
return bool(research_sources)

View File

@@ -1,8 +1,9 @@
"""
LinkedIn Content Generation Service for ALwrity
This service generates various types of LinkedIn content with enhanced grounding capabilities.
Integrated with Google Search, Gemini Grounded Provider, and quality analysis.
This service generates various types of LinkedIn content with provider-agnostic
LLM access via llm_text_gen. Research is handled by Exa/Tavily through the
common research infrastructure.
"""
import asyncio
@@ -21,57 +22,44 @@ from models.linkedin_models import (
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
GroundingLevel
)
from services.research import GoogleSearchService
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
from services.citation import CitationManager
from services.quality import ContentQualityAnalyzer
class LinkedInService:
"""
Enhanced LinkedIn content generation service with grounding capabilities.
LinkedIn content generation service with provider-agnostic LLM access.
This service integrates real research, grounded content generation,
citation management, and quality analysis for enterprise-grade content.
Uses llm_text_gen for text generation (respects GPT_PROVIDER).
Uses Exa/Tavily for research via common infrastructure.
"""
def __init__(self):
"""Initialize the LinkedIn service with all required components."""
# Google Search Service not used - removed to avoid false warnings
self.google_search = None
try:
self.gemini_grounded = GeminiGroundedProvider()
logger.info("✅ Gemini Grounded Provider initialized")
except Exception as e:
logger.warning(f"⚠️ Gemini Grounded Provider not available: {e}")
self.gemini_grounded = None
try:
self.citation_manager = CitationManager()
logger.info("✅ Citation Manager initialized")
except Exception as e:
logger.warning(f"⚠️ Citation Manager not available: {e}")
self.citation_manager = None
try:
self.quality_analyzer = ContentQualityAnalyzer()
logger.info("✅ Content Quality Analyzer initialized")
except Exception as e:
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
self.quality_analyzer = None
# Initialize fallback provider for non-grounded content
try:
from services.llm_providers.gemini_provider import gemini_structured_json_response, gemini_text_response
self.fallback_provider = {
'generate_structured_json': gemini_structured_json_response,
'generate_text': gemini_text_response
}
logger.info("✅ Fallback Gemini provider initialized")
except ImportError as e:
logger.warning(f"⚠️ Fallback Gemini provider not available: {e}")
self.fallback_provider = None
"""Initialize the LinkedIn service with lazy provider initialization."""
self._citation_manager = None
self._quality_analyzer = None
@property
def citation_manager(self):
if self._citation_manager is None:
try:
self._citation_manager = CitationManager()
logger.info("✅ Citation Manager initialized")
except Exception as e:
logger.warning(f"⚠️ Citation Manager not available: {e}")
self._citation_manager = None
return self._citation_manager
@property
def quality_analyzer(self):
if self._quality_analyzer is None:
try:
self._quality_analyzer = ContentQualityAnalyzer()
logger.info("✅ Content Quality Analyzer initialized")
except Exception as e:
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
self._quality_analyzer = None
return self._quality_analyzer
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
"""
@@ -94,8 +82,9 @@ class LinkedInService:
# Step 1: Conduct research if enabled
from services.linkedin.research_handler import ResearchHandler
research_handler = ResearchHandler(self)
user_id = str(getattr(request, 'user_id', '') or '')
research_sources, research_time = await research_handler.conduct_research(
request, request.research_enabled, request.search_engine, 10
request, request.research_enabled, request.search_engine, 10, user_id=user_id
)
# Step 2: Generate content based on grounding level
@@ -105,15 +94,14 @@ class LinkedInService:
from services.linkedin.content_generator import ContentGenerator
content_generator = ContentGenerator(
self.citation_manager,
self.quality_analyzer,
self.gemini_grounded,
self.fallback_provider
self.quality_analyzer
)
if grounding_enabled:
content_result = await content_generator.generate_grounded_post_content(
request=request,
research_sources=research_sources
research_sources=research_sources,
user_id=str(getattr(request, 'user_id', ''))
)
else:
logger.error("Grounding not enabled, Error generating LinkedIn post")
@@ -152,8 +140,9 @@ class LinkedInService:
# Step 1: Conduct research if enabled
from services.linkedin.research_handler import ResearchHandler
research_handler = ResearchHandler(self)
user_id = str(getattr(request, 'user_id', '') or '')
research_sources, research_time = await research_handler.conduct_research(
request, request.research_enabled, request.search_engine, 15
request, request.research_enabled, request.search_engine, 15, user_id=user_id
)
# Step 2: Generate content based on grounding level
@@ -163,15 +152,14 @@ class LinkedInService:
from services.linkedin.content_generator import ContentGenerator
content_generator = ContentGenerator(
self.citation_manager,
self.quality_analyzer,
self.gemini_grounded,
self.fallback_provider
self.quality_analyzer
)
if grounding_enabled:
content_result = await content_generator.generate_grounded_article_content(
request=request,
research_sources=research_sources
research_sources=research_sources,
user_id=str(getattr(request, 'user_id', ''))
)
else:
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
@@ -210,8 +198,9 @@ class LinkedInService:
# Step 1: Conduct research if enabled
from services.linkedin.research_handler import ResearchHandler
research_handler = ResearchHandler(self)
user_id = str(getattr(request, 'user_id', '') or '')
research_sources, research_time = await research_handler.conduct_research(
request, request.research_enabled, request.search_engine, 12
request, request.research_enabled, request.search_engine, 12, user_id=user_id
)
# Step 2: Generate content based on grounding level
@@ -221,15 +210,14 @@ class LinkedInService:
from services.linkedin.content_generator import ContentGenerator
content_generator = ContentGenerator(
self.citation_manager,
self.quality_analyzer,
self.gemini_grounded,
self.fallback_provider
self.quality_analyzer
)
if grounding_enabled:
content_result = await content_generator.generate_grounded_carousel_content(
request=request,
research_sources=research_sources
research_sources=research_sources,
user_id=str(getattr(request, 'user_id', ''))
)
else:
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
@@ -303,8 +291,9 @@ class LinkedInService:
# Step 1: Conduct research if enabled
from services.linkedin.research_handler import ResearchHandler
research_handler = ResearchHandler(self)
user_id = str(getattr(request, 'user_id', '') or '')
research_sources, research_time = await research_handler.conduct_research(
request, request.research_enabled, request.search_engine, 8
request, request.research_enabled, request.search_engine, 8, user_id=user_id
)
# Step 2: Generate content based on grounding level
@@ -314,15 +303,14 @@ class LinkedInService:
from services.linkedin.content_generator import ContentGenerator
content_generator = ContentGenerator(
self.citation_manager,
self.quality_analyzer,
self.gemini_grounded,
self.fallback_provider
self.quality_analyzer
)
if grounding_enabled:
content_result = await content_generator.generate_grounded_video_script_content(
request=request,
research_sources=research_sources
research_sources=research_sources,
user_id=str(getattr(request, 'user_id', ''))
)
else:
logger.error("Grounding not enabled - cannot generate LinkedIn video script without AI provider")
@@ -387,8 +375,9 @@ class LinkedInService:
# Step 1: Conduct research if enabled
from services.linkedin.research_handler import ResearchHandler
research_handler = ResearchHandler(self)
user_id = str(getattr(request, 'user_id', '') or '')
research_sources, research_time = await research_handler.conduct_research(
request, request.research_enabled, request.search_engine, 5
request, request.research_enabled, request.search_engine, 5, user_id=user_id
)
# Step 2: Generate response based on grounding level
@@ -398,15 +387,14 @@ class LinkedInService:
from services.linkedin.content_generator import ContentGenerator
content_generator = ContentGenerator(
self.citation_manager,
self.quality_analyzer,
self.gemini_grounded,
self.fallback_provider
self.quality_analyzer
)
if grounding_enabled:
response_result = await content_generator.generate_grounded_comment_response(
request=request,
research_sources=research_sources
research_sources=research_sources,
user_id=str(getattr(request, 'user_id', ''))
)
else:
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
@@ -423,20 +411,13 @@ class LinkedInService:
)
if result['success']:
# Convert to LinkedInCommentResponseResult
from models.linkedin_models import CommentResponse
comment_response = CommentResponse(
response=result['response'],
alternative_responses=result.get('alternative_responses', []),
tone_analysis=result.get('tone_analysis')
)
return LinkedInCommentResponseResult(
success=True,
data=comment_response,
research_sources=result['research_sources'],
generation_metadata=result['generation_metadata'],
grounding_status=result['grounding_status']
response=result['response'],
alternative_responses=result.get('alternative_responses', []),
tone_analysis=result.get('tone_analysis'),
generation_metadata=result.get('generation_metadata', {}),
grounding_status=result.get('grounding_status')
)
else:
return LinkedInCommentResponseResult(
@@ -451,35 +432,187 @@ class LinkedInService:
error=f"Failed to generate LinkedIn comment response: {str(e)}"
)
async def _conduct_research(self, topic: str, industry: str, search_engine: str, max_results: int = 10) -> List[ResearchSource]:
async def _conduct_research(self, topic: str, industry: str, search_engine: str, max_results: int = 10, user_id: str = None) -> List[ResearchSource]:
"""
Use native Google Search grounding instead of custom search.
The Gemini API handles search automatically when the google_search tool is enabled.
Conduct research using the configured search engine with caching.
For Exa: delegates to ExaResearchProvider.simple_search() with pre-flight validation
For Tavily: delegates to TavilyService.search() with pre-flight validation
For Google/unknown: falls back to Exa if available
Args:
topic: Research topic
industry: Target industry
search_engine: Search engine to use (google uses native grounding)
search_engine: Search engine to use (exa, tavily)
max_results: Maximum number of results to return
user_id: User ID for subscription pre-flight validation and usage tracking
Returns:
List of research sources (empty for google - sources come from grounding metadata)
List of research sources
"""
from services.cache.research_cache import research_cache
search_engine_lower = search_engine.lower().strip()
# Default to Exa if Google or unknown engine specified
if search_engine_lower in ("google", ""):
logger.info(f"Search engine '{search_engine}' not supported for direct research, defaulting to Exa")
search_engine_lower = "exa"
# Check cache first
cached_result = research_cache.get_cached_result(
keywords=[topic],
industry=industry,
target_audience="linkedin"
)
if cached_result:
logger.info(f"Returning cached research result for topic: {topic[:50]}")
# Convert cached dict back to ResearchSource objects
sources = []
for r in cached_result:
sources.append(ResearchSource(
title=r.get('title', 'Untitled'),
url=r.get('url', ''),
content=r.get('content', '')[:500],
relevance_score=r.get('relevance_score', 0.5),
credibility_score=r.get('credibility_score', 0.5),
source_type=r.get('source_type', 'web'),
publication_date=r.get('publication_date')
))
return sources
try:
# Debug: Log the search engine value received
logger.info(f"Received search engine: '{search_engine}' (type: {type(search_engine)})")
# Pre-flight validation if user_id provided
if user_id:
try:
from services.subscription.preflight_validator import validate_exa_research_operations
from services.database import get_session_for_user
from services.subscription import PricingService
import os
db_val = get_session_for_user(user_id)
if db_val:
try:
pricing_service = PricingService(db_val)
gpt_provider = os.getenv("GPT_PROVIDER", "google")
validate_exa_research_operations(pricing_service, user_id, gpt_provider)
finally:
db_val.close()
except Exception as preflight_err:
logger.warning(f"Research pre-flight validation failed: {preflight_err}")
# Continue anyway - don't block research for pre-flight issues
if search_engine_lower == "exa":
from services.research import get_exa_content_provider
try:
provider = get_exa_content_provider()
except RuntimeError:
logger.warning("Exa API key not configured, falling back to Tavily")
provider = None
if provider:
try:
results = await provider.simple_search(
query=f"{topic} {industry}",
num_results=max_results,
user_id=user_id
)
sources = []
for r in results:
sources.append(ResearchSource(
title=r.get('title', 'Untitled'),
url=r.get('url', ''),
content=r.get('text', '')[:500],
relevance_score=r.get('score', 0.5),
credibility_score=r.get('score', 0.5),
source_type='web',
publication_date=r.get('publishedDate')
))
# Cache the results
cache_data = [
{
'title': s.title,
'url': s.url,
'content': s.content,
'relevance_score': s.relevance_score,
'credibility_score': s.credibility_score,
'source_type': s.source_type,
'publication_date': s.publication_date
}
for s in sources
]
research_cache.cache_result(
keywords=[topic],
industry=industry,
target_audience="linkedin",
result=cache_data
)
logger.info(f"Exa research returned {len(sources)} sources for topic: {topic[:50]}")
return sources
except Exception as exa_err:
logger.warning(f"Exa research failed ({exa_err}), falling back to Tavily")
# Fallback to Tavily
search_engine_lower = "tavily"
elif search_engine_lower == "tavily":
from services.research.tavily_service import TavilyService
tavily_service = TavilyService()
if not tavily_service.enabled:
logger.warning("Tavily API key not configured, skipping Tavily research")
return []
result = await tavily_service.search(
query=f"{topic} {industry}",
max_results=max_results
)
raw_results = result.get('results', []) if isinstance(result, dict) else []
sources = []
for r in raw_results:
sources.append(ResearchSource(
title=r.get('title', 'Untitled'),
url=r.get('url', ''),
content=r.get('content', '')[:500],
relevance_score=r.get('score', r.get('relevance_score', 0.5)),
credibility_score=r.get('relevance_score', 0.5),
source_type='web',
publication_date=r.get('published_date')
))
# Cache the results
cache_data = [
{
'title': s.title,
'url': s.url,
'content': s.content,
'relevance_score': s.relevance_score,
'credibility_score': s.credibility_score,
'source_type': s.source_type,
'publication_date': s.publication_date
}
for s in sources
]
research_cache.cache_result(
keywords=[topic],
industry=industry,
target_audience="linkedin",
result=cache_data
)
logger.info(f"Tavily research returned {len(sources)} sources for topic: {topic[:50]}")
return sources
# Handle both enum value 'google' and enum name 'GOOGLE'
if search_engine.lower() == "google":
# No need for manual search - Gemini handles it automatically with native grounding
logger.info("Using native Google Search grounding via Gemini API - no manual search needed")
return [] # Return empty list - sources will come from grounding metadata
else:
# Fallback to basic research for other search engines
logger.error(f"Search engine {search_engine} not fully implemented, using fallback")
raise Exception(f"Search engine {search_engine} not fully implemented, using fallback")
logger.warning(f"Unknown search engine '{search_engine}', no research performed")
return []
except Exception as e:
logger.error(f"Error conducting research: {str(e)}")
# Fallback to basic research
raise Exception(f"Error conducting research: {str(e)}")
logger.error(f"Research failed for engine {search_engine}: {e}")
return []

View File

@@ -1,12 +1,13 @@
"""
LinkedIn Persona Service
Handles LinkedIn-specific persona generation and optimization.
Uses provider-agnostic llm_text_gen for LLM access.
"""
from typing import Dict, Any, Optional
from loguru import logger
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.llm_providers.main_text_generation import llm_text_gen
from .linkedin_persona_prompts import LinkedInPersonaPrompts
from .linkedin_persona_schemas import LinkedInPersonaSchemas
@@ -57,14 +58,15 @@ class LinkedInPersonaService:
# Extract user_id for tracking
user_id = onboarding_data.get("session_info", {}).get("user_id")
# Generate structured response using Gemini with optimized prompts
response = gemini_structured_json_response(
# Generate structured response using provider-agnostic gateway
response = llm_text_gen(
prompt=prompt,
schema=schema,
temperature=0.2,
max_tokens=4096,
json_struct=schema,
system_prompt=system_prompt,
user_id=user_id
user_id=user_id,
flow_type="linkedin_persona_generation",
max_tokens=4096,
temperature=0.2
)
if "error" in response:

View File

@@ -7,6 +7,7 @@ replacing mock research with real-time industry information.
Available Services:
- GoogleSearchService: Real-time industry research using Google Custom Search API
- ExaService: Competitor discovery and analysis using Exa API
- ExaContentResearchProvider: Shared content research provider for LinkedIn/Blog
- TavilyService: AI-powered web search with real-time information
- Source ranking and credibility assessment
- Content extraction and insight generation
@@ -17,12 +18,13 @@ Core Module (v2.0):
- ParameterOptimizer: AI-driven parameter optimization
Author: ALwrity Team
Version: 2.0
Last Updated: December 2025
Version: 2.1
Last Updated: June 2026
"""
from .google_search_service import GoogleSearchService
from .exa_service import ExaService
from .exa_content_research import ExaContentResearchProvider, get_exa_content_provider
from .tavily_service import TavilyService
# Core Research Engine (v2.0)
@@ -43,6 +45,10 @@ __all__ = [
"ExaService",
"TavilyService",
# Shared content research provider
"ExaContentResearchProvider",
"get_exa_content_provider",
# Core Research Engine (v2.0)
"ResearchEngine",
"ResearchContext",

View File

@@ -0,0 +1,198 @@
"""
Exa Content Research Provider
Shared Exa neural search provider for content research across ALwrity modules.
Provides simple_search() for fact-checking, content grounding, and research.
Used by:
- LinkedIn Writer (content generation research)
- Blog Writer (fact-checking and writing assistance)
This is the content-research variant. For competitor discovery/analysis,
use ExaService in exa_service.py.
"""
import os
import asyncio
from typing import List, Dict, Any, Optional
from loguru import logger
class ExaContentResearchProvider:
"""Exa neural search provider for content research."""
def __init__(self):
"""Initialize the Exa content research provider."""
self.api_key = os.getenv("EXA_API_KEY")
if not self.api_key:
raise RuntimeError("EXA_API_KEY not configured")
from exa_py import Exa
self.exa = Exa(self.api_key)
logger.info("✅ Exa Content Research Provider initialized")
async def simple_search(
self,
query: str,
num_results: int = 5,
user_id: str = None,
include_domains: List[str] = None,
exclude_domains: List[str] = None,
) -> List[Dict[str, Any]]:
"""
Simple Exa search for content research and fact-checking.
Handles subscription preflight check and usage tracking.
Args:
query: Search query string
num_results: Number of results to return (default 5)
user_id: Optional user ID for subscription checking
include_domains: Only return results from these domains
exclude_domains: Exclude results from these domains
Returns:
List of source dicts with title, url, text, publishedDate, author, score keys
Raises:
HTTPException(429): If user has exceeded subscription limits
Exception: If Exa API key not configured or search fails
"""
# Preflight subscription check
if user_id:
from models.subscription_models import APIProvider
from services.subscription import PricingService
from services.database import get_session_for_user
from fastapi import HTTPException
db = get_session_for_user(user_id)
if db:
try:
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=APIProvider.EXA,
tokens_requested=0,
actual_provider_name="exa",
)
if not can_proceed:
raise HTTPException(status_code=429, detail={
'error': 'insufficient_balance',
'message': message,
'provider': 'exa',
'usage_info': usage_info or {}
})
except HTTPException:
raise
except Exception as e:
logger.warning(f"[Exa simple_search] Preflight check failed: {e}")
finally:
try:
db.close()
except Exception:
pass
search_kwargs = {
"type": "auto",
"num_results": num_results,
"text": {"max_characters": 1000},
"highlights": {"num_sentences": 2, "highlights_per_url": 2},
}
if include_domains:
search_kwargs["include_domains"] = include_domains
if exclude_domains:
search_kwargs["exclude_domains"] = exclude_domains
try:
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
None,
lambda: self.exa.search_and_contents(query, **search_kwargs),
)
except Exception as e:
logger.error(f"[Exa simple_search] API call failed: {e}")
# Retry with simpler parameters
retry_kwargs = {"type": "auto", "num_results": num_results, "text": True}
if include_domains:
retry_kwargs["include_domains"] = include_domains
if exclude_domains:
retry_kwargs["exclude_domains"] = exclude_domains
try:
logger.info("[Exa simple_search] Retrying with simplified parameters")
results = await loop.run_in_executor(
None,
lambda: self.exa.search_and_contents(query, **retry_kwargs),
)
except Exception as retry_error:
logger.error(f"[Exa simple_search] Retry also failed: {retry_error}")
raise RuntimeError(f"Exa search failed: {str(retry_error)}") from retry_error
sources = []
for result in results.results:
sources.append({
'title': getattr(result, 'title', 'Untitled'),
'url': getattr(result, 'url', ''),
'text': getattr(result, 'text', ''),
'publishedDate': getattr(result, 'publishedDate', ''),
'author': getattr(result, 'author', ''),
'score': (lambda v: v if v is not None else 0.5)(getattr(result, 'score', 0.5)),
})
# Track usage
if user_id:
cost = 0.005 # ~0.5 cents per search
try:
self.track_usage(user_id, cost)
except Exception as e:
logger.warning(f"[Exa simple_search] Failed to track usage: {e}")
logger.info(f"[Exa simple_search] Found {len(sources)} sources for query: {query[:80]}...")
return sources
def track_usage(self, user_id: str, cost: float):
"""Track Exa API usage after successful call."""
from services.database import get_session_for_user
from services.subscription import PricingService
from sqlalchemy import text
db = get_session_for_user(user_id)
if not db:
logger.warning(f"[track_usage] Could not get DB session for user {user_id}")
return
try:
pricing_service = PricingService(db)
current_period = pricing_service.get_current_billing_period(user_id)
# Update exa_calls and exa_cost via SQL UPDATE
update_query = text("""
UPDATE usage_summaries
SET exa_calls = COALESCE(exa_calls, 0) + 1,
exa_cost = COALESCE(exa_cost, 0) + :cost,
total_calls = total_calls + 1,
total_cost = total_cost + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db.execute(update_query, {
'cost': cost,
'user_id': user_id,
'period': current_period
})
db.commit()
logger.info(f"[Exa] Tracked usage: user={user_id}, cost=${cost}")
except Exception as e:
logger.error(f"[Exa] Failed to track usage: {e}")
db.rollback()
finally:
db.close()
# Global singleton instance
_exa_content_provider: Optional[ExaContentResearchProvider] = None
def get_exa_content_provider() -> ExaContentResearchProvider:
"""Get or create the global Exa content research provider instance."""
global _exa_content_provider
if _exa_content_provider is None:
_exa_content_provider = ExaContentResearchProvider()
return _exa_content_provider