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:
@@ -36,6 +36,7 @@ class SearchEngine(str, Enum):
|
|||||||
METAPHOR = "metaphor"
|
METAPHOR = "metaphor"
|
||||||
GOOGLE = "google"
|
GOOGLE = "google"
|
||||||
TAVILY = "tavily"
|
TAVILY = "tavily"
|
||||||
|
EXA = "exa"
|
||||||
|
|
||||||
|
|
||||||
class GroundingLevel(str, Enum):
|
class GroundingLevel(str, Enum):
|
||||||
@@ -57,7 +58,7 @@ class LinkedInPostRequest(BaseModel):
|
|||||||
include_hashtags: bool = Field(default=True, description="Whether to include hashtags")
|
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")
|
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")
|
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)
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
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")
|
include_images: bool = Field(default=True, description="Whether to generate image suggestions")
|
||||||
seo_optimization: bool = Field(default=True, description="Whether to include SEO optimization")
|
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")
|
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)
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
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)
|
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_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")
|
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")
|
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")
|
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")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -144,9 +147,11 @@ class LinkedInCarouselRequest(BaseModel):
|
|||||||
"number_of_slides": 6,
|
"number_of_slides": 6,
|
||||||
"include_cover_slide": True,
|
"include_cover_slide": True,
|
||||||
"include_cta_slide": True,
|
"include_cta_slide": True,
|
||||||
|
"key_points": ["Remote collaboration tools", "Work-life balance", "Productivity metrics"],
|
||||||
"research_enabled": True,
|
"research_enabled": True,
|
||||||
"search_engine": "google",
|
"search_engine": "google",
|
||||||
"grounding_level": "enhanced",
|
"grounding_level": "enhanced",
|
||||||
|
"color_scheme": "professional",
|
||||||
"include_citations": True
|
"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)
|
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_captions: bool = Field(default=True, description="Whether to include captions")
|
||||||
include_thumbnail_suggestions: bool = Field(default=True, description="Whether to include thumbnail suggestions")
|
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")
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
|
||||||
@@ -176,6 +182,7 @@ class LinkedInVideoScriptRequest(BaseModel):
|
|||||||
"video_duration": 90,
|
"video_duration": 90,
|
||||||
"include_captions": True,
|
"include_captions": True,
|
||||||
"include_thumbnail_suggestions": True,
|
"include_thumbnail_suggestions": True,
|
||||||
|
"key_points": ["Zero trust architecture", "Phishing prevention", "Incident response"],
|
||||||
"research_enabled": True,
|
"research_enabled": True,
|
||||||
"search_engine": "google",
|
"search_engine": "google",
|
||||||
"grounding_level": "enhanced",
|
"grounding_level": "enhanced",
|
||||||
@@ -193,7 +200,7 @@ class LinkedInCommentResponseRequest(BaseModel):
|
|||||||
response_length: str = Field(default="medium", description="Length of response: short, medium, long")
|
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")
|
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")
|
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")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.BASIC, description="Level of content grounding")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -452,3 +459,23 @@ class LinkedInCommentResponseResult(BaseModel):
|
|||||||
generation_metadata: Dict[str, Any] = {}
|
generation_metadata: Dict[str, Any] = {}
|
||||||
error: Optional[str] = None
|
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
|
||||||
@@ -7,9 +7,10 @@ proper error handling, monitoring, and documentation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
|
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
|
from typing import Dict, Any, Optional
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -17,11 +18,17 @@ from models.linkedin_models import (
|
|||||||
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
||||||
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
|
LinkedInVideoScriptRequest, LinkedInCommentResponseRequest,
|
||||||
LinkedInPostResponse, LinkedInArticleResponse, LinkedInCarouselResponse,
|
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_service import LinkedInService
|
||||||
|
from services.linkedin.carousel import LinkedInCarouselPDFRenderer
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from utils.text_asset_tracker import save_and_track_text_content
|
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
|
# Initialize the LinkedIn service instance
|
||||||
linkedin_service = LinkedInService()
|
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 services.database import get_db as get_db_dependency
|
||||||
from sqlalchemy.orm import Session
|
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
|
# Initialize router
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/linkedin",
|
prefix="/api/linkedin",
|
||||||
@@ -112,10 +147,10 @@ async def generate_post(
|
|||||||
|
|
||||||
# Validate request
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -124,22 +159,30 @@ async def generate_post(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate post content
|
||||||
response = await linkedin_service.generate_linkedin_post(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
log_api_request, http_request, db, duration, 200
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.success:
|
# Save and track text content
|
||||||
raise HTTPException(status_code=500, detail=response.error)
|
|
||||||
|
|
||||||
# Save and track text content (non-blocking)
|
|
||||||
if user_id and response.data and response.data.content:
|
if user_id and response.data and response.data.content:
|
||||||
try:
|
try:
|
||||||
# Combine all text content
|
|
||||||
text_content = response.data.content
|
text_content = response.data.content
|
||||||
if response.data.call_to_action:
|
if response.data.call_to_action:
|
||||||
text_content += f"\n\nCall to Action: {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"
|
subdirectory="posts"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -177,14 +220,13 @@ async def generate_post(
|
|||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
logger.error(f"Error generating LinkedIn post: {str(e)}")
|
logger.error(f"Error generating LinkedIn post: {str(e)}")
|
||||||
|
|
||||||
# Log failed request
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 500
|
log_api_request, http_request, db, duration, 500
|
||||||
)
|
)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -234,17 +276,16 @@ async def generate_article(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate article content
|
||||||
response = await linkedin_service.generate_linkedin_article(request)
|
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:
|
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)
|
# Save and track text content (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
@@ -282,7 +323,7 @@ async def generate_article(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -300,7 +341,7 @@ async def generate_article(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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:
|
if request.number_of_slides < 3 or request.number_of_slides > 15:
|
||||||
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
|
raise HTTPException(status_code=422, detail=error_response(ERROR_CODES['VALIDATION'], "Number of slides must be between 3 and 15"))
|
||||||
|
|
||||||
# Extract user_id
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -352,18 +393,23 @@ async def generate_carousel(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate carousel content
|
||||||
response = await linkedin_service.generate_linkedin_carousel(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
try:
|
try:
|
||||||
@@ -381,10 +427,10 @@ async def generate_carousel(
|
|||||||
source_module="linkedin_writer",
|
source_module="linkedin_writer",
|
||||||
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
||||||
description=f"LinkedIn carousel for {request.industry} industry",
|
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(' ', '_')],
|
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
|
||||||
asset_metadata={
|
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_cover": response.data.cover_slide is not None,
|
||||||
"has_cta": response.data.cta_slide is not None
|
"has_cta": response.data.cta_slide is not None
|
||||||
},
|
},
|
||||||
@@ -392,7 +438,7 @@ async def generate_carousel(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -410,10 +456,82 @@ async def generate_carousel(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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(
|
@router.post(
|
||||||
"/generate-video-script",
|
"/generate-video-script",
|
||||||
response_model=LinkedInVideoScriptResponse,
|
response_model=LinkedInVideoScriptResponse,
|
||||||
@@ -447,14 +565,14 @@ async def generate_video_script(
|
|||||||
|
|
||||||
# Validate request
|
# Validate request
|
||||||
if not request.topic.strip():
|
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():
|
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))
|
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
|
||||||
if video_duration < 15 or video_duration > 300:
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -463,18 +581,23 @@ async def generate_video_script(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate video script content
|
||||||
response = await linkedin_service.generate_linkedin_video_script(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and response.data:
|
if user_id and response.data:
|
||||||
try:
|
try:
|
||||||
@@ -514,7 +637,7 @@ async def generate_video_script(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -532,7 +655,7 @@ async def generate_video_script(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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', ''))
|
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
|
||||||
|
|
||||||
if not original_comment.strip():
|
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():
|
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
|
# Extract user_id
|
||||||
user_id = None
|
user_id = None
|
||||||
@@ -584,18 +707,23 @@ async def generate_comment_response(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
user_id = http_request.headers.get("X-User-ID") or http_request.headers.get("Authorization")
|
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
|
# Generate comment response
|
||||||
response = await linkedin_service.generate_linkedin_comment_response(request)
|
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
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
log_api_request, http_request, db, duration, 200
|
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 (non-blocking)
|
||||||
if user_id and hasattr(response, 'response') and response.response:
|
if user_id and hasattr(response, 'response') and response.response:
|
||||||
try:
|
try:
|
||||||
@@ -626,7 +754,7 @@ async def generate_comment_response(
|
|||||||
file_extension=".md"
|
file_extension=".md"
|
||||||
)
|
)
|
||||||
except Exception as track_error:
|
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")
|
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
|
||||||
return response
|
return response
|
||||||
@@ -644,7 +772,7 @@ async def generate_comment_response(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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(
|
@router.get(
|
||||||
"/usage-stats",
|
"/usage-stats",
|
||||||
summary="Get Usage Statistics",
|
summary="Get Usage Statistics",
|
||||||
@@ -699,30 +949,29 @@ async def get_content_types():
|
|||||||
async def get_usage_stats(db: Session = Depends(get_db)):
|
async def get_usage_stats(db: Session = Depends(get_db)):
|
||||||
"""Get usage statistics for LinkedIn content generation."""
|
"""Get usage statistics for LinkedIn content generation."""
|
||||||
try:
|
try:
|
||||||
# This would query the database for actual usage stats
|
base = db.query(APIRequest).filter(APIRequest.path.like('/api/linkedin/%'))
|
||||||
# For now, returning mock data
|
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 {
|
return {
|
||||||
"total_requests": 1250,
|
"total_requests": total,
|
||||||
"content_types": {
|
"content_types": content_types,
|
||||||
"posts": 650,
|
"success_rate": round(successful / max(total, 1), 2),
|
||||||
"articles": 320,
|
"average_generation_time": round(float(avg_dur), 2),
|
||||||
"carousels": 180,
|
|
||||||
"video_scripts": 70,
|
|
||||||
"comment_responses": 30
|
|
||||||
},
|
|
||||||
"success_rate": 0.96,
|
|
||||||
"average_generation_time": 4.2,
|
|
||||||
"top_industries": [
|
|
||||||
"Technology",
|
|
||||||
"Healthcare",
|
|
||||||
"Finance",
|
|
||||||
"Marketing",
|
|
||||||
"Education"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving usage stats: {str(e)}")
|
logger.error(f"Error retrieving usage stats: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Failed to retrieve usage statistics"
|
detail=error_response(ERROR_CODES['GENERATION_FAILED'], "Failed to retrieve usage statistics")
|
||||||
)
|
)
|
||||||
@@ -17,13 +17,13 @@ from .content_generator_prompts import (
|
|||||||
VideoScriptGenerator
|
VideoScriptGenerator
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import new image generation services
|
# Import image generation services
|
||||||
from .image_generation import (
|
from .image_generation import (
|
||||||
LinkedInImageGenerator,
|
LinkedInImageGenerator,
|
||||||
LinkedInImageEditor,
|
|
||||||
LinkedInImageStorage
|
LinkedInImageStorage
|
||||||
)
|
)
|
||||||
from .image_prompts import LinkedInPromptGenerator
|
from .image_prompts import LinkedInPromptGenerator
|
||||||
|
from .carousel import LinkedInCarouselPDFRenderer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Content Generation
|
# Content Generation
|
||||||
@@ -42,9 +42,10 @@ __all__ = [
|
|||||||
|
|
||||||
# Image Generation Services
|
# Image Generation Services
|
||||||
'LinkedInImageGenerator',
|
'LinkedInImageGenerator',
|
||||||
'LinkedInImageEditor',
|
|
||||||
'LinkedInImageStorage',
|
'LinkedInImageStorage',
|
||||||
'LinkedInPromptGenerator'
|
'LinkedInPromptGenerator',
|
||||||
|
# Carousel Rendering
|
||||||
|
'LinkedInCarouselPDFRenderer',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Version information
|
# Version information
|
||||||
|
|||||||
3
backend/services/linkedin/carousel/__init__.py
Normal file
3
backend/services/linkedin/carousel/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .carousel_renderer import LinkedInCarouselPDFRenderer
|
||||||
|
|
||||||
|
__all__ = ['LinkedInCarouselPDFRenderer']
|
||||||
336
backend/services/linkedin/carousel/carousel_renderer.py
Normal file
336
backend/services/linkedin/carousel/carousel_renderer.py
Normal 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()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
Content Generator for LinkedIn Content Generation
|
Content Generator for LinkedIn Content Generation
|
||||||
|
|
||||||
Handles the main content generation logic for posts and articles.
|
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
|
from typing import Dict, Any, List, Optional
|
||||||
@@ -21,6 +22,7 @@ from services.linkedin.content_generator_prompts import (
|
|||||||
CarouselGenerator,
|
CarouselGenerator,
|
||||||
VideoScriptGenerator
|
VideoScriptGenerator
|
||||||
)
|
)
|
||||||
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
from services.persona_analysis_service import PersonaAnalysisService
|
from services.persona_analysis_service import PersonaAnalysisService
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -28,11 +30,9 @@ import time
|
|||||||
class ContentGenerator:
|
class ContentGenerator:
|
||||||
"""Handles content generation for all LinkedIn content types."""
|
"""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.citation_manager = citation_manager
|
||||||
self.quality_analyzer = quality_analyzer
|
self.quality_analyzer = quality_analyzer
|
||||||
self.gemini_grounded = gemini_grounded
|
|
||||||
self.fallback_provider = fallback_provider
|
|
||||||
|
|
||||||
# Persona caching
|
# Persona caching
|
||||||
self._persona_cache: Dict[str, Dict[str, Any]] = {}
|
self._persona_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -105,22 +105,24 @@ class ContentGenerator:
|
|||||||
del self._cache_timestamps[key]
|
del self._cache_timestamps[key]
|
||||||
logger.info(f"Cleared persona cache for user {user_id}")
|
logger.info(f"Cleared persona cache for user {user_id}")
|
||||||
|
|
||||||
def _transform_gemini_sources(self, gemini_sources):
|
def _build_research_context(self, research_sources: List) -> str:
|
||||||
"""Transform Gemini sources to ResearchSource format."""
|
"""Build research context string from research sources for prompt injection."""
|
||||||
transformed_sources = []
|
if not research_sources:
|
||||||
for source in gemini_sources:
|
return ""
|
||||||
transformed_source = ResearchSource(
|
|
||||||
title=source.get('title', 'Unknown Source'),
|
context_parts = ["\n\nRESEARCH CONTEXT (use this information to ground your content with facts and data):"]
|
||||||
url=source.get('url', ''),
|
for i, source in enumerate(research_sources[:5], 1): # Limit to top 5 sources
|
||||||
content=f"Source from {source.get('title', 'Unknown')}",
|
title = getattr(source, 'title', f'Source {i}')
|
||||||
relevance_score=0.8, # Default relevance score
|
url = getattr(source, 'url', '')
|
||||||
credibility_score=0.7, # Default credibility score
|
content = getattr(source, 'content', '')
|
||||||
domain_authority=0.6, # Default domain authority
|
context_parts.append(f"\n{i}. {title}")
|
||||||
source_type=source.get('type', 'web'),
|
if url:
|
||||||
publication_date=datetime.now().strftime('%Y-%m-%d')
|
context_parts.append(f" URL: {url}")
|
||||||
)
|
if content:
|
||||||
transformed_sources.append(transformed_source)
|
context_parts.append(f" Key insight: {content[:300]}")
|
||||||
return transformed_sources
|
|
||||||
|
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(
|
async def generate_post(
|
||||||
self,
|
self,
|
||||||
@@ -155,21 +157,12 @@ class ContentGenerator:
|
|||||||
logger.info(f" - First research source: {research_sources[0] if research_sources else 'None'}")
|
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]]}")
|
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 = []
|
citations = []
|
||||||
source_list = None
|
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 request.include_citations and research_sources and self.citation_manager:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
|
logger.info(f"Processing citations for content length: {len(content_result['content'])}")
|
||||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||||
@@ -224,7 +217,7 @@ class ContentGenerator:
|
|||||||
data=post_content,
|
data=post_content,
|
||||||
research_sources=final_research_sources, # Use final_research_sources
|
research_sources=final_research_sources, # Use final_research_sources
|
||||||
generation_metadata={
|
generation_metadata={
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -251,21 +244,12 @@ class ContentGenerator:
|
|||||||
try:
|
try:
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
# Step 3: Add citations if requested - ARTICLE METHOD
|
# Step 3: Add citations if requested
|
||||||
citations = []
|
citations = []
|
||||||
source_list = None
|
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 request.include_citations and research_sources and self.citation_manager:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
citations = self.citation_manager.extract_citations(content_result['content'])
|
citations = self.citation_manager.extract_citations(content_result['content'])
|
||||||
source_list = self.citation_manager.generate_source_list(research_sources)
|
source_list = self.citation_manager.generate_source_list(research_sources)
|
||||||
@@ -317,7 +301,7 @@ class ContentGenerator:
|
|||||||
data=article_content,
|
data=article_content,
|
||||||
research_sources=final_research_sources, # Use final_research_sources
|
research_sources=final_research_sources, # Use final_research_sources
|
||||||
generation_metadata={
|
generation_metadata={
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -386,7 +370,7 @@ class ContentGenerator:
|
|||||||
'alternative_responses': content_result.get('alternative_responses', []),
|
'alternative_responses': content_result.get('alternative_responses', []),
|
||||||
'tone_analysis': content_result.get('tone_analysis'),
|
'tone_analysis': content_result.get('tone_analysis'),
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
@@ -402,19 +386,14 @@ class ContentGenerator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Grounded content generation methods
|
# Grounded content generation methods
|
||||||
async def generate_grounded_post_content(self, request, research_sources: List) -> Dict[str, Any]:
|
async def generate_grounded_post_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded post content using the enhanced Gemini provider with native grounding."""
|
"""Generate post content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
try:
|
||||||
if not self.gemini_grounded:
|
# Build the prompt using persona if available
|
||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
uid = int(getattr(request, "user_id", 0) or 0)
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||||
|
|
||||||
# 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')
|
|
||||||
if getattr(request, 'persona_override', None):
|
if getattr(request, 'persona_override', None):
|
||||||
try:
|
try:
|
||||||
# Merge shallowly: override core and platform adaptation parts
|
|
||||||
override = request.persona_override
|
override = request.persona_override
|
||||||
if persona_data:
|
if persona_data:
|
||||||
core = persona_data.get('core_persona', {})
|
core = persona_data.get('core_persona', {})
|
||||||
@@ -431,61 +410,40 @@ class ContentGenerator:
|
|||||||
pass
|
pass
|
||||||
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
|
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_post",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_post",
|
||||||
max_tokens=request.max_length
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded post content: {str(e)}")
|
logger.error(f"Error generating post content: {str(e)}")
|
||||||
logger.info("Attempting fallback to standard content generation...")
|
raise Exception(f"Failed to generate LinkedIn post: {str(e)}")
|
||||||
|
|
||||||
# Fallback to standard content generation without grounding
|
async def generate_grounded_article_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
try:
|
"""Generate article content using provider-agnostic llm_text_gen."""
|
||||||
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)}")
|
|
||||||
|
|
||||||
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."""
|
|
||||||
try:
|
try:
|
||||||
if not self.gemini_grounded:
|
# Build the prompt using persona if available
|
||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
uid = int(getattr(request, "user_id", 0) or 0)
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
persona_data = self._get_cached_persona_data(uid, 'linkedin')
|
||||||
|
|
||||||
# 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')
|
|
||||||
if getattr(request, 'persona_override', None):
|
if getattr(request, 'persona_override', None):
|
||||||
try:
|
try:
|
||||||
override = request.persona_override
|
override = request.persona_override
|
||||||
@@ -504,88 +462,129 @@ class ContentGenerator:
|
|||||||
pass
|
pass
|
||||||
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
|
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_article",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_article",
|
||||||
max_tokens=request.word_count * 10 # Approximate character count
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded article content: {str(e)}")
|
logger.error(f"Error generating article content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_carousel_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded carousel content using the enhanced Gemini provider with native grounding."""
|
"""Generate carousel content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = CarouselPromptBuilder.build_carousel_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_carousel",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_carousel",
|
||||||
max_tokens=2000
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded carousel content: {str(e)}")
|
logger.error(f"Error generating carousel content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_video_script_content(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded video script content using the enhanced Gemini provider with native grounding."""
|
"""Generate video script content using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = VideoScriptPromptBuilder.build_video_script_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_video_script",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_video_script",
|
||||||
max_tokens=1500
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded video script content: {str(e)}")
|
logger.error(f"Error generating video script content: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded 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]:
|
async def generate_grounded_comment_response(self, request, research_sources: List, user_id: str = None) -> Dict[str, Any]:
|
||||||
"""Generate grounded comment response using the enhanced Gemini provider with native grounding."""
|
"""Generate comment response using provider-agnostic llm_text_gen."""
|
||||||
try:
|
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)
|
prompt = CommentResponsePromptBuilder.build_comment_response_prompt(request)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Inject research context into prompt
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
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,
|
prompt=prompt,
|
||||||
content_type="linkedin_comment_response",
|
user_id=user_id,
|
||||||
temperature=0.7,
|
flow_type="linkedin_comment_response",
|
||||||
max_tokens=2000
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generating grounded comment response: {str(e)}")
|
logger.error(f"Error generating comment response: {str(e)}")
|
||||||
raise Exception(f"Failed to generate grounded comment response: {str(e)}")
|
raise Exception(f"Failed to generate LinkedIn comment response: {str(e)}")
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class CarouselGenerator:
|
|||||||
'data': carousel_content,
|
'data': carousel_content,
|
||||||
'research_sources': research_sources,
|
'research_sources': research_sources,
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class VideoScriptGenerator:
|
|||||||
'data': video_script,
|
'data': video_script,
|
||||||
'research_sources': research_sources,
|
'research_sources': research_sources,
|
||||||
'generation_metadata': {
|
'generation_metadata': {
|
||||||
'model_used': 'gemini-2.0-flash-001',
|
'model_used': 'llm_text_gen',
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'research_time': research_time,
|
'research_time': research_time,
|
||||||
'grounding_enabled': grounding_enabled
|
'grounding_enabled': grounding_enabled
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
LinkedIn Image Generation Package
|
LinkedIn Image Generation Package
|
||||||
|
|
||||||
This package provides AI-powered image generation capabilities for LinkedIn content
|
This package provides AI-powered image generation capabilities for LinkedIn content
|
||||||
using Google's Gemini API. It includes image generation, editing, storage, and
|
using the common llm_providers infrastructure. It includes image generation, storage,
|
||||||
management services optimized for professional business use.
|
and management services optimized for professional business use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .linkedin_image_generator import LinkedInImageGenerator
|
from .linkedin_image_generator import LinkedInImageGenerator
|
||||||
from .linkedin_image_editor import LinkedInImageEditor
|
|
||||||
from .linkedin_image_storage import LinkedInImageStorage
|
from .linkedin_image_storage import LinkedInImageStorage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'LinkedInImageGenerator',
|
'LinkedInImageGenerator',
|
||||||
'LinkedInImageEditor',
|
|
||||||
'LinkedInImageStorage'
|
'LinkedInImageStorage'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Image Generator Service
|
LinkedIn Image Generator Service
|
||||||
|
|
||||||
This service generates LinkedIn-optimized images using Google's Gemini API.
|
This service generates LinkedIn-optimized images using the common
|
||||||
It provides professional, business-appropriate imagery for LinkedIn content.
|
llm_providers infrastructure. It provides professional, business-appropriate
|
||||||
|
imagery for LinkedIn content.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -17,6 +18,7 @@ from io import BytesIO
|
|||||||
# Import existing infrastructure
|
# Import existing infrastructure
|
||||||
from ...onboarding.api_key_manager import APIKeyManager
|
from ...onboarding.api_key_manager import APIKeyManager
|
||||||
from ...llm_providers.main_image_generation import generate_image
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,9 +26,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class LinkedInImageGenerator:
|
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 provides LinkedIn-specific image optimization, quality assurance,
|
||||||
and professional business aesthetics.
|
and professional business aesthetics.
|
||||||
"""
|
"""
|
||||||
@@ -36,10 +38,9 @@ class LinkedInImageGenerator:
|
|||||||
Initialize the LinkedIn Image Generator.
|
Initialize the LinkedIn Image Generator.
|
||||||
|
|
||||||
Args:
|
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.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.default_aspect_ratio = "1:1" # LinkedIn post optimal ratio
|
||||||
self.max_retries = 3
|
self.max_retries = 3
|
||||||
|
|
||||||
@@ -55,16 +56,18 @@ class LinkedInImageGenerator:
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
content_context: Dict[str, Any],
|
content_context: Dict[str, Any],
|
||||||
aspect_ratio: str = "1:1",
|
aspect_ratio: str = "1:1",
|
||||||
style_preference: str = "professional"
|
style_preference: str = "professional",
|
||||||
|
user_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate LinkedIn-optimized image using Gemini API.
|
Generate LinkedIn-optimized image using AI provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: User's image generation prompt
|
prompt: User's image generation prompt
|
||||||
content_context: LinkedIn content context (topic, industry, content_type)
|
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)
|
style_preference: Style preference (professional, creative, industry-specific)
|
||||||
|
user_id: User ID for tenant provider resolution
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing generation result, image data, and metadata
|
Dict containing generation result, image data, and metadata
|
||||||
@@ -78,8 +81,8 @@ class LinkedInImageGenerator:
|
|||||||
prompt, content_context, style_preference, aspect_ratio
|
prompt, content_context, style_preference, aspect_ratio
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate image using existing Gemini infrastructure
|
# Generate image using tenant-aware provider selection
|
||||||
generation_result = await self._generate_with_gemini(enhanced_prompt, aspect_ratio)
|
generation_result = await self._generate_with_provider(enhanced_prompt, aspect_ratio, user_id)
|
||||||
|
|
||||||
if not generation_result.get('success'):
|
if not generation_result.get('success'):
|
||||||
return {
|
return {
|
||||||
@@ -108,7 +111,7 @@ class LinkedInImageGenerator:
|
|||||||
'aspect_ratio': aspect_ratio,
|
'aspect_ratio': aspect_ratio,
|
||||||
'content_context': content_context,
|
'content_context': content_context,
|
||||||
'generation_time': generation_time,
|
'generation_time': generation_time,
|
||||||
'model_used': self.model,
|
'model_used': generation_result.get('model'),
|
||||||
'image_format': processed_image['format'],
|
'image_format': processed_image['format'],
|
||||||
'image_size': processed_image['size'],
|
'image_size': processed_image['size'],
|
||||||
'resolution': processed_image['resolution']
|
'resolution': processed_image['resolution']
|
||||||
@@ -131,17 +134,19 @@ class LinkedInImageGenerator:
|
|||||||
|
|
||||||
async def edit_image(
|
async def edit_image(
|
||||||
self,
|
self,
|
||||||
base_image: bytes,
|
input_image_bytes: bytes,
|
||||||
edit_prompt: str,
|
edit_prompt: str,
|
||||||
content_context: Dict[str, Any]
|
content_context: Dict[str, Any],
|
||||||
|
user_id: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Edit existing image using Gemini's conversational editing capabilities.
|
Edit existing image using unified image editing infrastructure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_image: Base image data in bytes
|
input_image_bytes: Input image bytes to edit
|
||||||
edit_prompt: Description of desired edits
|
edit_prompt: Description of desired edits
|
||||||
content_context: LinkedIn content context for optimization
|
content_context: LinkedIn content context for optimization
|
||||||
|
user_id: User ID for tenant provider resolution and subscription checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing edited image result and metadata
|
Dict containing edited image result and metadata
|
||||||
@@ -155,18 +160,46 @@ class LinkedInImageGenerator:
|
|||||||
edit_prompt, content_context
|
edit_prompt, content_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use Gemini's image editing capabilities
|
# Use unified image editing system.
|
||||||
# Note: This will be implemented when Gemini's image editing is fully available
|
# common_edit_image() handles: provider resolution, pre-flight validation,
|
||||||
# For now, we'll return a placeholder implementation
|
# 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 {
|
if result and result.image_bytes:
|
||||||
'success': False,
|
generation_time = (datetime.now() - start_time).total_seconds()
|
||||||
'error': 'Image editing not yet implemented - coming in next Gemini API update',
|
logger.info(
|
||||||
'generation_time': (datetime.now() - start_time).total_seconds()
|
"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:
|
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 {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f"Image editing failed: {str(e)}",
|
'error': f"Image editing failed: {str(e)}",
|
||||||
@@ -268,13 +301,16 @@ class LinkedInImageGenerator:
|
|||||||
|
|
||||||
return enhanced_edit_prompt
|
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.
|
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:
|
Args:
|
||||||
prompt: Enhanced prompt for image generation
|
prompt: Enhanced prompt for image generation
|
||||||
aspect_ratio: Desired aspect ratio
|
aspect_ratio: Desired aspect ratio
|
||||||
|
user_id: User ID for tenant provider resolution and subscription checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Generation result from image generation provider
|
Generation result from image generation provider
|
||||||
@@ -285,26 +321,31 @@ class LinkedInImageGenerator:
|
|||||||
"1:1": (1024, 1024),
|
"1:1": (1024, 1024),
|
||||||
"16:9": (1920, 1080),
|
"16:9": (1920, 1080),
|
||||||
"4:3": (1366, 1024),
|
"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))
|
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(
|
result = generate_image(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
options={
|
options={
|
||||||
"provider": "gemini", # LinkedIn uses Gemini by default
|
|
||||||
"model": self.model if hasattr(self, 'model') else None,
|
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
}
|
},
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if result and result.image_bytes:
|
if result and result.image_bytes:
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'image_data': result.image_bytes,
|
'image_data': result.image_bytes,
|
||||||
'image_path': None, # No file path, using bytes directly
|
'image_path': None,
|
||||||
'width': result.width,
|
'width': result.width,
|
||||||
'height': result.height,
|
'height': result.height,
|
||||||
'provider': result.provider,
|
'provider': result.provider,
|
||||||
@@ -487,6 +528,9 @@ class LinkedInImageGenerator:
|
|||||||
(1.6, 1.8), # 16:9 (landscape)
|
(1.6, 1.8), # 16:9 (landscape)
|
||||||
(0.7, 0.8), # 4:3 (portrait)
|
(0.7, 0.8), # 4:3 (portrait)
|
||||||
(1.2, 1.4), # 5:4 (landscape)
|
(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:
|
for min_ratio, max_ratio in suitable_ratios:
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ It provides secure storage, efficient retrieval, and metadata management for gen
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -58,6 +60,8 @@ class LinkedInImageStorage:
|
|||||||
self.max_storage_size_gb = 10 # Maximum storage size in GB
|
self.max_storage_size_gb = 10 # Maximum storage size in GB
|
||||||
self.image_retention_days = 30 # Days to keep images
|
self.image_retention_days = 30 # Days to keep images
|
||||||
self.max_image_size_mb = 10 # Maximum individual image size in MB
|
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}")
|
logger.info(f"LinkedIn Image Storage initialized at {self.base_storage_path}")
|
||||||
|
|
||||||
@@ -102,6 +106,22 @@ class LinkedInImageStorage:
|
|||||||
try:
|
try:
|
||||||
start_time = datetime.now()
|
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
|
# Generate unique image ID
|
||||||
image_id = self._generate_image_id(image_data, metadata)
|
image_id = self._generate_image_id(image_data, metadata)
|
||||||
|
|
||||||
@@ -170,6 +190,9 @@ class LinkedInImageStorage:
|
|||||||
Dict containing image data and metadata
|
Dict containing image data and metadata
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not self._validate_image_id(image_id):
|
||||||
|
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||||
|
|
||||||
# Find image file
|
# Find image file
|
||||||
image_path = await self._find_image_by_id(image_id, user_id)
|
image_path = await self._find_image_by_id(image_id, user_id)
|
||||||
if not image_path:
|
if not image_path:
|
||||||
@@ -216,6 +239,9 @@ class LinkedInImageStorage:
|
|||||||
Dict containing deletion result
|
Dict containing deletion result
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not self._validate_image_id(image_id):
|
||||||
|
return {'success': False, 'error': f'Invalid image ID format: {image_id}'}
|
||||||
|
|
||||||
# Find image file
|
# Find image file
|
||||||
image_path = await self._find_image_by_id(image_id, user_id)
|
image_path = await self._find_image_by_id(image_id, user_id)
|
||||||
if not image_path:
|
if not image_path:
|
||||||
@@ -418,6 +444,32 @@ class LinkedInImageStorage:
|
|||||||
'error': f"Failed to get storage stats: {str(e)}"
|
'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:
|
def _generate_image_id(self, image_data: bytes, metadata: Dict[str, Any]) -> str:
|
||||||
"""Generate unique image ID based on content and metadata."""
|
"""Generate unique image ID based on content and metadata."""
|
||||||
# Create hash from image data and key metadata
|
# Create hash from image data and key metadata
|
||||||
@@ -569,6 +621,9 @@ class LinkedInImageStorage:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict containing image metadata if found
|
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)
|
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]]:
|
async def _load_metadata(self, image_id: str, user_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
LinkedIn Image Prompts Package
|
LinkedIn Image Prompts Package
|
||||||
|
|
||||||
This package provides AI-powered image prompt generation for LinkedIn content
|
This package provides AI-powered image prompt generation for LinkedIn content
|
||||||
using Google's Gemini API. It creates three distinct prompt styles optimized
|
using the provider-agnostic llm_text_gen gateway. It creates three distinct
|
||||||
for professional business image generation.
|
prompt styles optimized for professional business image generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .linkedin_prompt_generator import LinkedInPromptGenerator
|
from .linkedin_prompt_generator import LinkedInPromptGenerator
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Image Prompt Generator Service
|
LinkedIn Image Prompt Generator Service
|
||||||
|
|
||||||
This service generates AI-optimized image prompts for LinkedIn content using Gemini's
|
This service generates AI-optimized image prompts for LinkedIn content using
|
||||||
capabilities. It creates three distinct prompt styles (professional, creative, industry-specific)
|
the provider-agnostic llm_text_gen gateway. It creates three distinct prompt
|
||||||
following best practices for image generation.
|
styles (professional, creative, industry-specific) following best practices
|
||||||
|
for image generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -13,14 +14,14 @@ from loguru import logger
|
|||||||
|
|
||||||
# Import existing infrastructure
|
# Import existing infrastructure
|
||||||
from ...onboarding.api_key_manager import APIKeyManager
|
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:
|
class LinkedInPromptGenerator:
|
||||||
"""
|
"""
|
||||||
Generates AI-optimized image prompts for LinkedIn content.
|
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
|
1. Professional Style - Corporate aesthetics, clean lines, business colors
|
||||||
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
|
2. Creative Style - Engaging visuals, vibrant colors, social media appeal
|
||||||
3. Industry-Specific Style - Tailored to specific business sectors
|
3. Industry-Specific Style - Tailored to specific business sectors
|
||||||
@@ -31,10 +32,9 @@ class LinkedInPromptGenerator:
|
|||||||
Initialize the LinkedIn Prompt Generator.
|
Initialize the LinkedIn Prompt Generator.
|
||||||
|
|
||||||
Args:
|
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.api_key_manager = api_key_manager or APIKeyManager()
|
||||||
self.model = "gemini-2.0-flash-exp"
|
|
||||||
|
|
||||||
# Prompt generation configuration
|
# Prompt generation configuration
|
||||||
self.max_prompt_length = 500
|
self.max_prompt_length = 500
|
||||||
@@ -49,7 +49,8 @@ class LinkedInPromptGenerator:
|
|||||||
async def generate_three_prompts(
|
async def generate_three_prompts(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str = "1:1"
|
aspect_ratio: str = "1:1",
|
||||||
|
user_id: str = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Generate three AI-optimized image prompts for LinkedIn content.
|
Generate three AI-optimized image prompts for LinkedIn content.
|
||||||
@@ -57,6 +58,7 @@ class LinkedInPromptGenerator:
|
|||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
|
linkedin_content: LinkedIn content context (topic, industry, content_type, content)
|
||||||
aspect_ratio: Desired image aspect ratio
|
aspect_ratio: Desired image aspect ratio
|
||||||
|
user_id: User ID for subscription checking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of three prompt objects with style, prompt, and description
|
List of three prompt objects with style, prompt, and description
|
||||||
@@ -65,11 +67,11 @@ class LinkedInPromptGenerator:
|
|||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
|
logger.info(f"Generating image prompts for LinkedIn content: {linkedin_content.get('topic', 'Unknown')}")
|
||||||
|
|
||||||
# Generate prompts using Gemini
|
# Generate prompts using provider-agnostic gateway
|
||||||
prompts = await self._generate_prompts_with_gemini(linkedin_content, aspect_ratio)
|
prompts = await self._generate_prompts_with_llm(linkedin_content, aspect_ratio, user_id)
|
||||||
|
|
||||||
if not prompts or len(prompts) < 3:
|
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)
|
prompts = self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
# Ensure exactly 3 prompts
|
# Ensure exactly 3 prompts
|
||||||
@@ -92,62 +94,65 @@ class LinkedInPromptGenerator:
|
|||||||
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
|
logger.error(f"Error generating LinkedIn image prompts: {str(e)}")
|
||||||
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
return self._get_fallback_prompts(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
async def _generate_prompts_with_gemini(
|
async def _generate_prompts_with_llm(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str
|
aspect_ratio: str,
|
||||||
|
user_id: str = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Generate image prompts using Gemini AI.
|
Generate image prompts using provider-agnostic llm_text_gen.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
aspect_ratio: Image aspect ratio
|
aspect_ratio: Image aspect ratio
|
||||||
|
user_id: User ID for subscription checking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of generated prompts
|
List of generated prompts
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build the prompt for Gemini
|
# Build the prompt
|
||||||
gemini_prompt = self._build_gemini_prompt(linkedin_content, aspect_ratio)
|
prompt = self._build_image_prompt(linkedin_content, aspect_ratio)
|
||||||
|
|
||||||
# Generate response using Gemini
|
# Generate response using provider-agnostic gateway
|
||||||
response = gemini_text_response(
|
response = llm_text_gen(
|
||||||
prompt=gemini_prompt,
|
prompt=prompt,
|
||||||
temperature=0.7,
|
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization.",
|
||||||
top_p=0.8,
|
user_id=user_id,
|
||||||
n=1,
|
flow_type="linkedin_image_prompts",
|
||||||
max_tokens=1000,
|
max_tokens=1000,
|
||||||
system_prompt="You are an expert AI image prompt engineer specializing in LinkedIn content optimization."
|
temperature=0.7
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
logger.warning("No response from Gemini prompt generation")
|
logger.warning("No response from prompt generation")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Parse Gemini response into structured prompts
|
# Parse response into structured prompts
|
||||||
prompts = self._parse_gemini_response(response, linkedin_content)
|
response_text = response if isinstance(response, str) else str(response or "")
|
||||||
|
prompts = self._parse_llm_response(response_text, linkedin_content)
|
||||||
|
|
||||||
return prompts
|
return prompts
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in Gemini prompt generation: {str(e)}")
|
logger.error(f"Error in prompt generation: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _build_gemini_prompt(
|
def _build_image_prompt(
|
||||||
self,
|
self,
|
||||||
linkedin_content: Dict[str, Any],
|
linkedin_content: Dict[str, Any],
|
||||||
aspect_ratio: str
|
aspect_ratio: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build comprehensive prompt for Gemini to generate image prompts.
|
Build comprehensive prompt for LLM to generate image prompts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
aspect_ratio: Image aspect ratio
|
aspect_ratio: Image aspect ratio
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Formatted prompt for Gemini
|
Formatted prompt for LLM
|
||||||
"""
|
"""
|
||||||
topic = linkedin_content.get('topic', 'business')
|
topic = linkedin_content.get('topic', 'business')
|
||||||
industry = linkedin_content.get('industry', 'business')
|
industry = linkedin_content.get('industry', 'business')
|
||||||
@@ -428,16 +433,16 @@ class LinkedInPromptGenerator:
|
|||||||
else:
|
else:
|
||||||
return 'Informational & Awareness'
|
return 'Informational & Awareness'
|
||||||
|
|
||||||
def _parse_gemini_response(
|
def _parse_llm_response(
|
||||||
self,
|
self,
|
||||||
response: str,
|
response: str,
|
||||||
linkedin_content: Dict[str, Any]
|
linkedin_content: Dict[str, Any]
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Parse Gemini response into structured prompt objects.
|
Parse LLM response into structured prompt objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: Raw response from Gemini
|
response: Raw response from LLM
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -462,7 +467,7 @@ class LinkedInPromptGenerator:
|
|||||||
return self._parse_response_manually(response, linkedin_content)
|
return self._parse_response_manually(response, linkedin_content)
|
||||||
|
|
||||||
except Exception as e:
|
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)
|
return self._parse_response_manually(response, linkedin_content)
|
||||||
|
|
||||||
def _parse_response_manually(
|
def _parse_response_manually(
|
||||||
@@ -474,7 +479,7 @@ class LinkedInPromptGenerator:
|
|||||||
Manually parse response if JSON parsing fails.
|
Manually parse response if JSON parsing fails.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: Raw response from Gemini
|
response: Raw response from LLM
|
||||||
linkedin_content: LinkedIn content context
|
linkedin_content: LinkedIn content context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
Research Handler for LinkedIn Content Generation
|
Research Handler for LinkedIn Content Generation
|
||||||
|
|
||||||
Handles research operations and timing for 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 datetime import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from models.linkedin_models import ResearchSource
|
from models.linkedin_models import ResearchSource
|
||||||
@@ -21,11 +22,19 @@ class ResearchHandler:
|
|||||||
request,
|
request,
|
||||||
research_enabled: bool,
|
research_enabled: bool,
|
||||||
search_engine: str,
|
search_engine: str,
|
||||||
max_results: int = 10
|
max_results: int = 10,
|
||||||
|
user_id: Optional[str] = None
|
||||||
) -> tuple[List[ResearchSource], float]:
|
) -> tuple[List[ResearchSource], float]:
|
||||||
"""
|
"""
|
||||||
Conduct research if enabled and return sources with timing.
|
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:
|
Returns:
|
||||||
Tuple of (research_sources, research_time)
|
Tuple of (research_sources, research_time)
|
||||||
"""
|
"""
|
||||||
@@ -33,7 +42,6 @@ class ResearchHandler:
|
|||||||
research_time = 0
|
research_time = 0
|
||||||
|
|
||||||
if research_enabled:
|
if research_enabled:
|
||||||
# Debug: Log the search engine value being passed
|
|
||||||
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
|
logger.info(f"ResearchHandler: search_engine='{search_engine}' (type: {type(search_engine)})")
|
||||||
|
|
||||||
research_start = datetime.now()
|
research_start = datetime.now()
|
||||||
@@ -41,7 +49,8 @@ class ResearchHandler:
|
|||||||
topic=request.topic,
|
topic=request.topic,
|
||||||
industry=request.industry,
|
industry=request.industry,
|
||||||
search_engine=search_engine,
|
search_engine=search_engine,
|
||||||
max_results=max_results
|
max_results=max_results,
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
research_time = (datetime.now() - research_start).total_seconds()
|
research_time = (datetime.now() - research_start).total_seconds()
|
||||||
logger.info(f"Research completed in {research_time:.2f}s, found {len(research_sources)} sources")
|
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':
|
if not research_enabled or level == 'none':
|
||||||
return False
|
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
|
# For other engines, require that research actually returned sources
|
||||||
return bool(research_sources)
|
return bool(research_sources)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Content Generation Service for ALwrity
|
LinkedIn Content Generation Service for ALwrity
|
||||||
|
|
||||||
This service generates various types of LinkedIn content with enhanced grounding capabilities.
|
This service generates various types of LinkedIn content with provider-agnostic
|
||||||
Integrated with Google Search, Gemini Grounded Provider, and quality analysis.
|
LLM access via llm_text_gen. Research is handled by Exa/Tavily through the
|
||||||
|
common research infrastructure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -21,57 +22,44 @@ from models.linkedin_models import (
|
|||||||
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
|
HashtagSuggestion, ImageSuggestion, Citation, ContentQualityMetrics,
|
||||||
GroundingLevel
|
GroundingLevel
|
||||||
)
|
)
|
||||||
from services.research import GoogleSearchService
|
|
||||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
|
||||||
from services.citation import CitationManager
|
from services.citation import CitationManager
|
||||||
from services.quality import ContentQualityAnalyzer
|
from services.quality import ContentQualityAnalyzer
|
||||||
|
|
||||||
|
|
||||||
class LinkedInService:
|
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,
|
Uses llm_text_gen for text generation (respects GPT_PROVIDER).
|
||||||
citation management, and quality analysis for enterprise-grade content.
|
Uses Exa/Tavily for research via common infrastructure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the LinkedIn service with all required components."""
|
"""Initialize the LinkedIn service with lazy provider initialization."""
|
||||||
# Google Search Service not used - removed to avoid false warnings
|
self._citation_manager = None
|
||||||
self.google_search = None
|
self._quality_analyzer = None
|
||||||
|
|
||||||
try:
|
@property
|
||||||
self.gemini_grounded = GeminiGroundedProvider()
|
def citation_manager(self):
|
||||||
logger.info("✅ Gemini Grounded Provider initialized")
|
if self._citation_manager is None:
|
||||||
except Exception as e:
|
try:
|
||||||
logger.warning(f"⚠️ Gemini Grounded Provider not available: {e}")
|
self._citation_manager = CitationManager()
|
||||||
self.gemini_grounded = None
|
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
|
||||||
|
|
||||||
try:
|
@property
|
||||||
self.citation_manager = CitationManager()
|
def quality_analyzer(self):
|
||||||
logger.info("✅ Citation Manager initialized")
|
if self._quality_analyzer is None:
|
||||||
except Exception as e:
|
try:
|
||||||
logger.warning(f"⚠️ Citation Manager not available: {e}")
|
self._quality_analyzer = ContentQualityAnalyzer()
|
||||||
self.citation_manager = None
|
logger.info("✅ Content Quality Analyzer initialized")
|
||||||
|
except Exception as e:
|
||||||
try:
|
logger.warning(f"⚠️ Content Quality Analyzer not available: {e}")
|
||||||
self.quality_analyzer = ContentQualityAnalyzer()
|
self._quality_analyzer = None
|
||||||
logger.info("✅ Content Quality Analyzer initialized")
|
return self._quality_analyzer
|
||||||
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
|
|
||||||
|
|
||||||
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
|
async def generate_linkedin_post(self, request: LinkedInPostRequest) -> LinkedInPostResponse:
|
||||||
"""
|
"""
|
||||||
@@ -94,8 +82,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -105,15 +94,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_post_content(
|
content_result = await content_generator.generate_grounded_post_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled, Error generating LinkedIn post")
|
logger.error("Grounding not enabled, Error generating LinkedIn post")
|
||||||
@@ -152,8 +140,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -163,15 +152,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_article_content(
|
content_result = await content_generator.generate_grounded_article_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn article without AI provider")
|
||||||
@@ -210,8 +198,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -221,15 +210,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_carousel_content(
|
content_result = await content_generator.generate_grounded_carousel_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn carousel without AI provider")
|
||||||
@@ -303,8 +291,9 @@ class LinkedInService:
|
|||||||
# Step 1: Conduct research if enabled
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate content based on grounding level
|
||||||
@@ -314,15 +303,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
content_result = await content_generator.generate_grounded_video_script_content(
|
content_result = await content_generator.generate_grounded_video_script_content(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn video script without AI provider")
|
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
|
# Step 1: Conduct research if enabled
|
||||||
from services.linkedin.research_handler import ResearchHandler
|
from services.linkedin.research_handler import ResearchHandler
|
||||||
research_handler = ResearchHandler(self)
|
research_handler = ResearchHandler(self)
|
||||||
|
user_id = str(getattr(request, 'user_id', '') or '')
|
||||||
research_sources, research_time = await research_handler.conduct_research(
|
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
|
# Step 2: Generate response based on grounding level
|
||||||
@@ -398,15 +387,14 @@ class LinkedInService:
|
|||||||
from services.linkedin.content_generator import ContentGenerator
|
from services.linkedin.content_generator import ContentGenerator
|
||||||
content_generator = ContentGenerator(
|
content_generator = ContentGenerator(
|
||||||
self.citation_manager,
|
self.citation_manager,
|
||||||
self.quality_analyzer,
|
self.quality_analyzer
|
||||||
self.gemini_grounded,
|
|
||||||
self.fallback_provider
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if grounding_enabled:
|
if grounding_enabled:
|
||||||
response_result = await content_generator.generate_grounded_comment_response(
|
response_result = await content_generator.generate_grounded_comment_response(
|
||||||
request=request,
|
request=request,
|
||||||
research_sources=research_sources
|
research_sources=research_sources,
|
||||||
|
user_id=str(getattr(request, 'user_id', ''))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
|
logger.error("Grounding not enabled - cannot generate LinkedIn comment response without AI provider")
|
||||||
@@ -423,20 +411,13 @@ class LinkedInService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result['success']:
|
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(
|
return LinkedInCommentResponseResult(
|
||||||
success=True,
|
success=True,
|
||||||
data=comment_response,
|
response=result['response'],
|
||||||
research_sources=result['research_sources'],
|
alternative_responses=result.get('alternative_responses', []),
|
||||||
generation_metadata=result['generation_metadata'],
|
tone_analysis=result.get('tone_analysis'),
|
||||||
grounding_status=result['grounding_status']
|
generation_metadata=result.get('generation_metadata', {}),
|
||||||
|
grounding_status=result.get('grounding_status')
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return LinkedInCommentResponseResult(
|
return LinkedInCommentResponseResult(
|
||||||
@@ -451,35 +432,187 @@ class LinkedInService:
|
|||||||
error=f"Failed to generate LinkedIn comment response: {str(e)}"
|
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.
|
Conduct research using the configured search engine with caching.
|
||||||
The Gemini API handles search automatically when the google_search tool is enabled.
|
|
||||||
|
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:
|
Args:
|
||||||
topic: Research topic
|
topic: Research topic
|
||||||
industry: Target industry
|
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
|
max_results: Maximum number of results to return
|
||||||
|
user_id: User ID for subscription pre-flight validation and usage tracking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of research sources (empty for google - sources come from grounding metadata)
|
List of research sources
|
||||||
"""
|
"""
|
||||||
try:
|
from services.cache.research_cache import research_cache
|
||||||
# Debug: Log the search engine value received
|
|
||||||
logger.info(f"Received search engine: '{search_engine}' (type: {type(search_engine)})")
|
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:
|
||||||
|
# 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:
|
else:
|
||||||
# Fallback to basic research for other search engines
|
logger.warning(f"Unknown search engine '{search_engine}', no research performed")
|
||||||
logger.error(f"Search engine {search_engine} not fully implemented, using fallback")
|
return []
|
||||||
raise Exception(f"Search engine {search_engine} not fully implemented, using fallback")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error conducting research: {str(e)}")
|
logger.error(f"Research failed for engine {search_engine}: {e}")
|
||||||
# Fallback to basic research
|
return []
|
||||||
raise Exception(f"Error conducting research: {str(e)}")
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
LinkedIn Persona Service
|
LinkedIn Persona Service
|
||||||
Handles LinkedIn-specific persona generation and optimization.
|
Handles LinkedIn-specific persona generation and optimization.
|
||||||
|
Uses provider-agnostic llm_text_gen for LLM access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from loguru import logger
|
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_prompts import LinkedInPersonaPrompts
|
||||||
from .linkedin_persona_schemas import LinkedInPersonaSchemas
|
from .linkedin_persona_schemas import LinkedInPersonaSchemas
|
||||||
|
|
||||||
@@ -57,14 +58,15 @@ class LinkedInPersonaService:
|
|||||||
# Extract user_id for tracking
|
# Extract user_id for tracking
|
||||||
user_id = onboarding_data.get("session_info", {}).get("user_id")
|
user_id = onboarding_data.get("session_info", {}).get("user_id")
|
||||||
|
|
||||||
# Generate structured response using Gemini with optimized prompts
|
# Generate structured response using provider-agnostic gateway
|
||||||
response = gemini_structured_json_response(
|
response = llm_text_gen(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
schema=schema,
|
json_struct=schema,
|
||||||
temperature=0.2,
|
|
||||||
max_tokens=4096,
|
|
||||||
system_prompt=system_prompt,
|
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:
|
if "error" in response:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ replacing mock research with real-time industry information.
|
|||||||
Available Services:
|
Available Services:
|
||||||
- GoogleSearchService: Real-time industry research using Google Custom Search API
|
- GoogleSearchService: Real-time industry research using Google Custom Search API
|
||||||
- ExaService: Competitor discovery and analysis using Exa 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
|
- TavilyService: AI-powered web search with real-time information
|
||||||
- Source ranking and credibility assessment
|
- Source ranking and credibility assessment
|
||||||
- Content extraction and insight generation
|
- Content extraction and insight generation
|
||||||
@@ -17,12 +18,13 @@ Core Module (v2.0):
|
|||||||
- ParameterOptimizer: AI-driven parameter optimization
|
- ParameterOptimizer: AI-driven parameter optimization
|
||||||
|
|
||||||
Author: ALwrity Team
|
Author: ALwrity Team
|
||||||
Version: 2.0
|
Version: 2.1
|
||||||
Last Updated: December 2025
|
Last Updated: June 2026
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .google_search_service import GoogleSearchService
|
from .google_search_service import GoogleSearchService
|
||||||
from .exa_service import ExaService
|
from .exa_service import ExaService
|
||||||
|
from .exa_content_research import ExaContentResearchProvider, get_exa_content_provider
|
||||||
from .tavily_service import TavilyService
|
from .tavily_service import TavilyService
|
||||||
|
|
||||||
# Core Research Engine (v2.0)
|
# Core Research Engine (v2.0)
|
||||||
@@ -43,6 +45,10 @@ __all__ = [
|
|||||||
"ExaService",
|
"ExaService",
|
||||||
"TavilyService",
|
"TavilyService",
|
||||||
|
|
||||||
|
# Shared content research provider
|
||||||
|
"ExaContentResearchProvider",
|
||||||
|
"get_exa_content_provider",
|
||||||
|
|
||||||
# Core Research Engine (v2.0)
|
# Core Research Engine (v2.0)
|
||||||
"ResearchEngine",
|
"ResearchEngine",
|
||||||
"ResearchContext",
|
"ResearchContext",
|
||||||
|
|||||||
198
backend/services/research/exa_content_research.py
Normal file
198
backend/services/research/exa_content_research.py
Normal 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
|
||||||
@@ -1,22 +1,41 @@
|
|||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Snackbar, Alert, CircularProgress } from '@mui/material';
|
||||||
|
import { Save as SaveIcon } from '@mui/icons-material';
|
||||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||||
import { useCopilotReadable, useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
|
||||||
import '@copilotkit/react-ui/styles.css';
|
import '@copilotkit/react-ui/styles.css';
|
||||||
import './styles/alwrity-copilot.css';
|
import './styles/alwrity-copilot.css';
|
||||||
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
||||||
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
||||||
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
||||||
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker } from './components';
|
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker, type ProgressStep } from './components';
|
||||||
import { useCopilotActions } from './components/CopilotActions';
|
import { useCopilotActions } from './components/CopilotActions';
|
||||||
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
||||||
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
||||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { saveLinkedInToAssetLibrary } from '../../services/linkedInWriterApi';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
import { useContentPlanningStore } from '../../stores/contentPlanningStore';
|
||||||
|
import { useWorkflowStore } from '../../stores/workflowStore';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
|
|
||||||
// Optional debug flag: set to true to enable verbose logs locally
|
// Optional debug flag: set to true to enable verbose logs locally
|
||||||
// const DEBUG_LINKEDIN = false;
|
// const DEBUG_LINKEDIN = false;
|
||||||
|
|
||||||
|
const observabilityHooks = {
|
||||||
|
onChatExpanded: () => {
|
||||||
|
console.log('[LinkedIn Writer] Sidebar opened');
|
||||||
|
},
|
||||||
|
onMessageSent: (message: any) => {
|
||||||
|
const text = typeof message === 'string' ? message : (message?.content ?? '');
|
||||||
|
if (text) {
|
||||||
|
console.log('[LinkedIn Writer] User message tracked:', { content_length: text.length });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFeedbackGiven: (id: string, type: string) => {
|
||||||
|
console.log('[LinkedIn Writer] Feedback given:', { id, type });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface LinkedInWriterProps {
|
interface LinkedInWriterProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -60,6 +79,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setDraft,
|
setDraft,
|
||||||
|
setChatHistory,
|
||||||
setIsPreviewing,
|
setIsPreviewing,
|
||||||
setLivePreviewHtml,
|
setLivePreviewHtml,
|
||||||
setPendingEdit,
|
setPendingEdit,
|
||||||
@@ -78,7 +98,13 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
// Utilities
|
// Utilities
|
||||||
getHistoryLength,
|
getHistoryLength,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
summarizeHistory
|
summarizeHistory,
|
||||||
|
|
||||||
|
// Direct generation methods
|
||||||
|
generatePost,
|
||||||
|
generateArticle,
|
||||||
|
generateCarousel,
|
||||||
|
generateVideoScript
|
||||||
} = useLinkedInWriter();
|
} = useLinkedInWriter();
|
||||||
|
|
||||||
// Get persona context for enhanced AI assistance
|
// Get persona context for enhanced AI assistance
|
||||||
@@ -102,6 +128,86 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
getStorageStats
|
getStorageStats
|
||||||
} = useCopilotPersistence();
|
} = useCopilotPersistence();
|
||||||
|
|
||||||
|
// Read calendar topic from navigation state (e.g. from Calendar tab)
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { completeTask } = useWorkflowStore();
|
||||||
|
const locationState = location.state as {
|
||||||
|
calendarTopic?: string;
|
||||||
|
calendarDescription?: string;
|
||||||
|
calendarEventId?: string;
|
||||||
|
workflowTaskId?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
// Pre-fill context from calendar event on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const topic = locationState?.calendarTopic;
|
||||||
|
if (topic) {
|
||||||
|
const description = locationState?.calendarDescription || '';
|
||||||
|
const contextText = `Topic: ${topic}${description ? `\nDescription: ${description}` : ''}`;
|
||||||
|
handleContextChange(contextText);
|
||||||
|
// Clear navigation state so refresh doesn't re-trigger
|
||||||
|
window.history.replaceState({}, document.title);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Save to Asset Library + Mark Calendar Event Complete ──────
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string | null>(null);
|
||||||
|
const { updateEvent } = useContentPlanningStore();
|
||||||
|
|
||||||
|
const handleSaveToAssetLibrary = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setSaveStatus('saving');
|
||||||
|
setSaveErrorMessage(null);
|
||||||
|
try {
|
||||||
|
const topic = context?.startsWith('Topic:')
|
||||||
|
? context.replace(/^Topic:\s*/, '').split('\n')[0].trim()
|
||||||
|
: undefined;
|
||||||
|
const title = draft.split('\n')[0].substring(0, 100) || 'LinkedIn Post';
|
||||||
|
|
||||||
|
await saveLinkedInToAssetLibrary({
|
||||||
|
title,
|
||||||
|
content: draft,
|
||||||
|
topic,
|
||||||
|
tags: ['linkedin_post', 'social_media'],
|
||||||
|
assetMetadata: {
|
||||||
|
word_count: draft.split(/\s+/).length,
|
||||||
|
source: locationState?.calendarTopic ? 'calendar' : 'manual',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark the originating calendar event as published
|
||||||
|
if (locationState?.calendarEventId) {
|
||||||
|
try {
|
||||||
|
await updateEvent(locationState.calendarEventId, { status: 'published' });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LinkedInWriter] Failed to update calendar event status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the workflow task as completed (for calendar-sourced tasks)
|
||||||
|
if (locationState?.workflowTaskId) {
|
||||||
|
try {
|
||||||
|
await completeTask(locationState.workflowTaskId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LinkedInWriter] Failed to complete workflow task:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus('saved');
|
||||||
|
|
||||||
|
// Navigate back to dashboard after a brief delay so the user sees "saved"
|
||||||
|
setTimeout(() => navigate('/dashboard'), 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.response?.data?.detail || err?.message || 'Please try again.';
|
||||||
|
console.error('[LinkedInWriter] Save failed:', err);
|
||||||
|
setSaveErrorMessage(message);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Sync component state with enhanced persistence
|
// Sync component state with enhanced persistence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[LinkedIn Writer] Component mounted, enhanced persistence enabled');
|
console.log('[LinkedIn Writer] Component mounted, enhanced persistence enabled');
|
||||||
@@ -110,22 +216,34 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
const loadPersistedData = () => {
|
const loadPersistedData = () => {
|
||||||
try {
|
try {
|
||||||
// Load chat history
|
// Load chat history
|
||||||
const chatHistory = loadChatHistory();
|
const persistedChatHistory = loadChatHistory();
|
||||||
console.log(`📖 Loaded ${chatHistory.length} persisted chat messages`);
|
if (persistedChatHistory.length > 0) {
|
||||||
|
setChatHistory(persistedChatHistory.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
ts: m.timestamp || Date.now(),
|
||||||
|
action: m.metadata?.action,
|
||||||
|
result: m.metadata?.result
|
||||||
|
})));
|
||||||
|
console.log(`📖 Restored ${persistedChatHistory.length} persisted chat messages`);
|
||||||
|
}
|
||||||
|
|
||||||
// Load user preferences
|
// Load user preferences
|
||||||
const persistedPrefs = loadPersistedPreferences();
|
const persistedPrefs = loadPersistedPreferences();
|
||||||
console.log('📖 Loaded persisted user preferences:', persistedPrefs);
|
if (persistedPrefs) {
|
||||||
|
setUserPreferences(persistedPrefs);
|
||||||
|
console.log('📖 Restored persisted user preferences');
|
||||||
|
}
|
||||||
|
|
||||||
// Load conversation context
|
// Load conversation context (for future use)
|
||||||
const conversationContext = loadConversationContext();
|
const conversationContext = loadConversationContext();
|
||||||
console.log('📖 Loaded persisted conversation context:', conversationContext);
|
console.log('📖 Loaded persisted conversation context:', conversationContext);
|
||||||
|
|
||||||
// Load draft content
|
// Load draft content
|
||||||
const persistedDraft = loadDraftContent();
|
const persistedDraft = loadDraftContent();
|
||||||
if (persistedDraft && !draft) {
|
if (persistedDraft && !draft) {
|
||||||
console.log('📖 Restoring persisted draft content');
|
setDraft(persistedDraft);
|
||||||
// Note: We'll need to integrate this with the useLinkedInWriter hook
|
console.log('📖 Restored persisted draft content');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load last session
|
// Load last session
|
||||||
@@ -182,13 +300,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
savePersistedPreferences(prefs);
|
savePersistedPreferences(prefs);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Share current draft and context with CopilotKit for better context awareness
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'Current LinkedIn content draft the user is editing',
|
|
||||||
value: draft,
|
|
||||||
categories: ['social', 'linkedin', 'draft']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-save draft content when it changes
|
// Auto-save draft content when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft && draft.trim().length > 0) {
|
if (draft && draft.trim().length > 0) {
|
||||||
@@ -196,12 +307,6 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
}
|
}
|
||||||
}, [draft, saveDraftContent]);
|
}, [draft, saveDraftContent]);
|
||||||
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'User context and notes for LinkedIn content',
|
|
||||||
value: context,
|
|
||||||
categories: ['social', 'linkedin', 'context']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow Copilot to update the draft directly
|
// Allow Copilot to update the draft directly
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'updateLinkedInDraft',
|
name: 'updateLinkedInDraft',
|
||||||
@@ -239,6 +344,81 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
setDraft
|
setDraft
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const labels = useMemo(() => ({
|
||||||
|
title: 'ALwrity Co-Pilot',
|
||||||
|
initial: draft
|
||||||
|
? 'Great! I can see you have content to work with. Use the quick edit suggestions below to refine your post in real-time, or ask me to make specific changes.'
|
||||||
|
: `Hi! I'm your ALwrity Co-Pilot, your LinkedIn writing assistant${corePersona ? ` with ${corePersona.persona_name} persona optimization` : ''}. I can help you create professional posts, articles, carousels, video scripts, and comment responses. Try the new persona-aware actions for enhanced content generation!`
|
||||||
|
}), [draft, corePersona]);
|
||||||
|
|
||||||
|
const makeSystemMessage = useCallback((context: string, additional?: string) => {
|
||||||
|
const prefs = userPreferences;
|
||||||
|
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
||||||
|
const history = summarizeHistory();
|
||||||
|
const historyLine = history ? `Recent conversation (last 15 messages):\n${history}` : '';
|
||||||
|
const currentDraft = draft ? `Current draft content:\n${draft}` : 'No current draft content.';
|
||||||
|
const tone = prefs.tone || 'professional';
|
||||||
|
const industry = prefs.industry || 'Technology';
|
||||||
|
const audience = prefs.target_audience || 'professionals';
|
||||||
|
|
||||||
|
const personaGuidance = corePersona && platformPersona ? `
|
||||||
|
PERSONA-AWARE WRITING GUIDANCE:
|
||||||
|
- PERSONA: ${corePersona.persona_name} (${corePersona.archetype})
|
||||||
|
- CORE BELIEF: ${corePersona.core_belief}
|
||||||
|
- CONFIDENCE SCORE: ${corePersona.confidence_score}%
|
||||||
|
- LINGUISTIC STYLE: ${corePersona.linguistic_fingerprint?.sentence_metrics?.average_sentence_length_words || 'Unknown'} words average, ${corePersona.linguistic_fingerprint?.sentence_metrics?.active_to_passive_ratio || 'Unknown'} active/passive ratio
|
||||||
|
- GO-TO WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.go_to_words?.join(', ') || 'None specified'}
|
||||||
|
- AVOID WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.avoid_words?.join(', ') || 'None specified'}
|
||||||
|
|
||||||
|
PLATFORM OPTIMIZATION (LinkedIn):
|
||||||
|
- CHARACTER LIMIT: ${platformPersona.content_format_rules?.character_limit || '3000'} characters
|
||||||
|
- OPTIMAL LENGTH: ${platformPersona.content_format_rules?.optimal_length || '150-300 words'}
|
||||||
|
- ENGAGEMENT PATTERN: ${platformPersona.engagement_patterns?.posting_frequency || '2-3 times per week'}
|
||||||
|
- HASHTAG STRATEGY: ${platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'}
|
||||||
|
|
||||||
|
ALWAYS generate content that matches this persona's linguistic fingerprint and platform optimization rules.` : '';
|
||||||
|
|
||||||
|
const guidance = `
|
||||||
|
You are ALwrity's LinkedIn Writing Assistant specializing in ${industry} content.
|
||||||
|
|
||||||
|
CRITICAL CONSTRAINTS:
|
||||||
|
- TONE: Always maintain a ${tone} tone throughout all content
|
||||||
|
- INDUSTRY: Focus specifically on ${industry} industry context and terminology
|
||||||
|
- AUDIENCE: Target content specifically for ${audience}
|
||||||
|
- QUALITY: Ensure all content meets LinkedIn professional standards
|
||||||
|
${personaGuidance ? `\n${personaGuidance}` : ''}
|
||||||
|
|
||||||
|
CURRENT CONTEXT:
|
||||||
|
${currentDraft}
|
||||||
|
|
||||||
|
Available LinkedIn content tools:
|
||||||
|
- generateLinkedInPost: Create ${tone} LinkedIn posts for ${industry} ${audience}
|
||||||
|
- generateLinkedInArticle: Write ${tone} thought leadership articles about ${industry}
|
||||||
|
- generateLinkedInCarousel: Design ${tone} multi-slide carousels for ${industry} insights
|
||||||
|
- generateLinkedInVideoScript: Create ${tone} video scripts for ${industry} topics
|
||||||
|
- generateLinkedInCommentResponse: Draft ${tone} responses appropriate for ${industry}
|
||||||
|
|
||||||
|
🎭 ENHANCED PERSONA-AWARE ACTIONS (Recommended):
|
||||||
|
- generateLinkedInPostWithPersona: Create posts optimized for your writing style and platform constraints
|
||||||
|
- generateLinkedInArticleWithPersona: Write articles with persona-aware optimization
|
||||||
|
- validateContentAgainstPersona: Validate existing content against your persona
|
||||||
|
- getPersonaWritingSuggestions: Get personalized writing recommendations
|
||||||
|
|
||||||
|
DIRECT DRAFT ACTIONS:
|
||||||
|
- updateLinkedInDraft: Replace the entire draft with new content
|
||||||
|
- appendToLinkedInDraft: Add text to the existing draft
|
||||||
|
- editLinkedInDraft: Apply quick edits (Casual, Professional, TightenHook, AddCTA, Shorten, Lengthen) to the current draft
|
||||||
|
|
||||||
|
IMPORTANT: When refining or editing content, always reference the current draft above. If the user asks to refine their post, use the current draft content as the starting point. Never ask for content that already exists in the draft.
|
||||||
|
|
||||||
|
For quick edits, use editLinkedInDraft with the appropriate operation. This will show a live preview of changes before applying them.
|
||||||
|
|
||||||
|
Use user preferences, context, conversation history, and persona data to personalize all content.
|
||||||
|
Always respect the user's preferred ${tone} tone, ${industry} industry focus, and writing persona style.
|
||||||
|
Always use the most appropriate tool for the user's request.`.trim();
|
||||||
|
return [prefsLine, historyLine, currentDraft, guidance, additional].filter(Boolean).join('\n\n');
|
||||||
|
}, [draft, userPreferences, corePersona, platformPersona, summarizeHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`linkedin-writer ${className}`}
|
className={`linkedin-writer ${className}`}
|
||||||
@@ -269,7 +449,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
height: progressActive || progressSteps.length > 0 ? 'auto' : 0,
|
height: progressActive || progressSteps.length > 0 ? 'auto' : 0,
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
<ProgressTracker steps={progressSteps as any} active={progressActive} />
|
<ProgressTracker steps={progressSteps as ProgressStep[]} active={progressActive} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -286,7 +466,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
currentAction={currentAction}
|
currentAction={currentAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
{draft || isGenerating ? (<>
|
{draft || isGenerating ? (<>
|
||||||
{/* Editor Panel - Show when there's content or generating */}
|
{/* Editor Panel - Show when there's content or generating */}
|
||||||
<ContentEditor
|
<ContentEditor
|
||||||
@@ -310,16 +490,56 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
topic={context ? context.split('\n')[0].substring(0, 50) : undefined}
|
topic={context ? context.split('\n')[0].substring(0, 50) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Save to Asset Library button - only when there's generated content */}
|
||||||
|
{draft && !isGenerating && (
|
||||||
|
<div style={{ padding: '8px 24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={saveStatus === 'saving' ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
|
||||||
|
onClick={handleSaveToAssetLibrary}
|
||||||
|
disabled={saveStatus === 'saving' || saveStatus === 'saved'}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{saveStatus === 'saving' ? 'Saving...' :
|
||||||
|
saveStatus === 'saved' ? 'Saved ✓' :
|
||||||
|
'Save to Asset Library'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</>) : (
|
</>) : (
|
||||||
/* Welcome Message - Show when no content */
|
/* Welcome Message - Show when no content */
|
||||||
<WelcomeMessage
|
<WelcomeMessage
|
||||||
draft={draft}
|
draft={draft}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
|
onGeneratePost={generatePost}
|
||||||
|
onGenerateArticle={generateArticle}
|
||||||
|
onGenerateCarousel={generateCarousel}
|
||||||
|
onGenerateVideoScript={generateVideoScript}
|
||||||
|
userPreferences={userPreferences}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Save feedback snackbar */}
|
||||||
|
<Snackbar
|
||||||
|
open={saveStatus === 'saved' || saveStatus === 'error'}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => { setSaveStatus('idle'); setSaveErrorMessage(null); }}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={saveStatus === 'saved' ? 'success' : 'error'}
|
||||||
|
variant="filled"
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{saveStatus === 'saved'
|
||||||
|
? 'LinkedIn post saved to Asset Library!'
|
||||||
|
: `Failed to save: ${saveErrorMessage || 'Please try again.'}`}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
{/* Register CopilotKit Actions */}
|
{/* Register CopilotKit Actions */}
|
||||||
<RegisterLinkedInActions />
|
<RegisterLinkedInActions />
|
||||||
<RegisterLinkedInEditActions />
|
<RegisterLinkedInEditActions />
|
||||||
@@ -330,95 +550,10 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
{/* CopilotKit Sidebar */}
|
{/* CopilotKit Sidebar */}
|
||||||
<CopilotSidebar
|
<CopilotSidebar
|
||||||
className="alwrity-copilot-sidebar linkedin-writer"
|
className="alwrity-copilot-sidebar linkedin-writer"
|
||||||
labels={{
|
labels={labels}
|
||||||
title: 'ALwrity Co-Pilot',
|
|
||||||
initial: draft ?
|
|
||||||
'Great! I can see you have content to work with. Use the quick edit suggestions below to refine your post in real-time, or ask me to make specific changes.' :
|
|
||||||
`Hi! I'm your ALwrity Co-Pilot, your LinkedIn writing assistant${corePersona ? ` with ${corePersona.persona_name} persona optimization` : ''}. I can help you create professional posts, articles, carousels, video scripts, and comment responses. Try the new persona-aware actions for enhanced content generation!`
|
|
||||||
}}
|
|
||||||
suggestions={getIntelligentSuggestions}
|
suggestions={getIntelligentSuggestions}
|
||||||
makeSystemMessage={(context: string, additional?: string) => {
|
makeSystemMessage={makeSystemMessage}
|
||||||
const prefs = userPreferences;
|
observabilityHooks={observabilityHooks}
|
||||||
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
|
||||||
const history = summarizeHistory();
|
|
||||||
const historyLine = history ? `Recent conversation (last 15 messages):\n${history}` : '';
|
|
||||||
const currentDraft = draft ? `Current draft content:\n${draft}` : 'No current draft content.';
|
|
||||||
const tone = prefs.tone || 'professional';
|
|
||||||
const industry = prefs.industry || 'Technology';
|
|
||||||
const audience = prefs.target_audience || 'professionals';
|
|
||||||
|
|
||||||
// Enhanced persona-aware guidance
|
|
||||||
const personaGuidance = corePersona && platformPersona ? `
|
|
||||||
PERSONA-AWARE WRITING GUIDANCE:
|
|
||||||
- PERSONA: ${corePersona.persona_name} (${corePersona.archetype})
|
|
||||||
- CORE BELIEF: ${corePersona.core_belief}
|
|
||||||
- CONFIDENCE SCORE: ${corePersona.confidence_score}%
|
|
||||||
- LINGUISTIC STYLE: ${corePersona.linguistic_fingerprint?.sentence_metrics?.average_sentence_length_words || 'Unknown'} words average, ${corePersona.linguistic_fingerprint?.sentence_metrics?.active_to_passive_ratio || 'Unknown'} active/passive ratio
|
|
||||||
- GO-TO WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.go_to_words?.join(', ') || 'None specified'}
|
|
||||||
- AVOID WORDS: ${corePersona.linguistic_fingerprint?.lexical_features?.avoid_words?.join(', ') || 'None specified'}
|
|
||||||
|
|
||||||
PLATFORM OPTIMIZATION (LinkedIn):
|
|
||||||
- CHARACTER LIMIT: ${platformPersona.content_format_rules?.character_limit || '3000'} characters
|
|
||||||
- OPTIMAL LENGTH: ${platformPersona.content_format_rules?.optimal_length || '150-300 words'}
|
|
||||||
- ENGAGEMENT PATTERN: ${platformPersona.engagement_patterns?.posting_frequency || '2-3 times per week'}
|
|
||||||
- HASHTAG STRATEGY: ${platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'}
|
|
||||||
|
|
||||||
ALWAYS generate content that matches this persona's linguistic fingerprint and platform optimization rules.` : '';
|
|
||||||
|
|
||||||
const guidance = `
|
|
||||||
You are ALwrity's LinkedIn Writing Assistant specializing in ${industry} content.
|
|
||||||
|
|
||||||
CRITICAL CONSTRAINTS:
|
|
||||||
- TONE: Always maintain a ${tone} tone throughout all content
|
|
||||||
- INDUSTRY: Focus specifically on ${industry} industry context and terminology
|
|
||||||
- AUDIENCE: Target content specifically for ${audience}
|
|
||||||
- QUALITY: Ensure all content meets LinkedIn professional standards
|
|
||||||
${personaGuidance ? `\n${personaGuidance}` : ''}
|
|
||||||
|
|
||||||
CURRENT CONTEXT:
|
|
||||||
${currentDraft}
|
|
||||||
|
|
||||||
Available LinkedIn content tools:
|
|
||||||
- generateLinkedInPost: Create ${tone} LinkedIn posts for ${industry} ${audience}
|
|
||||||
- generateLinkedInArticle: Write ${tone} thought leadership articles about ${industry}
|
|
||||||
- generateLinkedInCarousel: Design ${tone} multi-slide carousels for ${industry} insights
|
|
||||||
- generateLinkedInVideoScript: Create ${tone} video scripts for ${industry} topics
|
|
||||||
- generateLinkedInCommentResponse: Draft ${tone} responses appropriate for ${industry}
|
|
||||||
|
|
||||||
🎭 ENHANCED PERSONA-AWARE ACTIONS (Recommended):
|
|
||||||
- generateLinkedInPostWithPersona: Create posts optimized for your writing style and platform constraints
|
|
||||||
- generateLinkedInArticleWithPersona: Write articles with persona-aware optimization
|
|
||||||
- validateContentAgainstPersona: Validate existing content against your persona
|
|
||||||
- getPersonaWritingSuggestions: Get personalized writing recommendations
|
|
||||||
|
|
||||||
DIRECT DRAFT ACTIONS:
|
|
||||||
- updateLinkedInDraft: Replace the entire draft with new content
|
|
||||||
- appendToLinkedInDraft: Add text to the existing draft
|
|
||||||
- editLinkedInDraft: Apply quick edits (Casual, Professional, TightenHook, AddCTA, Shorten, Lengthen) to the current draft
|
|
||||||
|
|
||||||
IMPORTANT: When refining or editing content, always reference the current draft above. If the user asks to refine their post, use the current draft content as the starting point. Never ask for content that already exists in the draft.
|
|
||||||
|
|
||||||
For quick edits, use editLinkedInDraft with the appropriate operation. This will show a live preview of changes before applying them.
|
|
||||||
|
|
||||||
Use user preferences, context, conversation history, and persona data to personalize all content.
|
|
||||||
Always respect the user's preferred ${tone} tone, ${industry} industry focus, and writing persona style.
|
|
||||||
Always use the most appropriate tool for the user's request.`.trim();
|
|
||||||
return [prefsLine, historyLine, currentDraft, guidance, additional].filter(Boolean).join('\n\n');
|
|
||||||
}}
|
|
||||||
observabilityHooks={{
|
|
||||||
onChatExpanded: () => {
|
|
||||||
console.log('[LinkedIn Writer] Sidebar opened');
|
|
||||||
},
|
|
||||||
onMessageSent: (message: any) => {
|
|
||||||
const text = typeof message === 'string' ? message : (message?.content ?? '');
|
|
||||||
if (text) {
|
|
||||||
console.log('[LinkedIn Writer] User message tracked:', { content_length: text.length });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFeedbackGiven: (id: string, type: string) => {
|
|
||||||
console.log('[LinkedIn Writer] Feedback given:', { id, type });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
import { showToastNotification } from '../../utils/toastNotifications';
|
||||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||||
import {
|
import {
|
||||||
mapPostType,
|
mapPostType,
|
||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
readPrefs
|
readPrefs
|
||||||
} from './utils/linkedInWriterUtils';
|
} from './utils/linkedInWriterUtils';
|
||||||
import { apiClient } from '../../api/client';
|
import { apiClient } from '../../api/client';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
|
||||||
|
|
||||||
const RegisterLinkedInActions: React.FC = () => {
|
const RegisterLinkedInActions: React.FC = () => {
|
||||||
// LinkedIn Image Generation Actions
|
// LinkedIn Image Generation Actions
|
||||||
@@ -53,7 +52,12 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'prompt', type: 'string', required: true, description: 'The image generation prompt' },
|
{ name: 'prompt', type: 'string', required: true, description: 'The image generation prompt' },
|
||||||
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style' },
|
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type, and style', attributes: [
|
||||||
|
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||||
|
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||||
|
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||||
|
{ name: 'style', type: 'string', required: true, description: 'Writing style/tone' }
|
||||||
|
] },
|
||||||
{ name: 'aspect_ratio', type: 'string', required: false, description: 'Image aspect ratio (default: 1:1)' }
|
{ name: 'aspect_ratio', type: 'string', required: false, description: 'Image aspect ratio (default: 1:1)' }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
@@ -88,6 +92,54 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// LinkedIn Image Editing Action
|
||||||
|
useCopilotActionTyped({
|
||||||
|
name: 'editLinkedInImage',
|
||||||
|
description: 'Edit an existing LinkedIn image using natural language (change style, background, colors, etc.). Requires an image_id from a previously generated LinkedIn image.',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'image_id', type: 'string', required: true, description: 'ID of the previously generated LinkedIn image to edit' },
|
||||||
|
{ name: 'prompt', type: 'string', required: true, description: 'Natural language description of desired edits (e.g., "Make the background blue", "Add more professional look")' },
|
||||||
|
{ name: 'content_context', type: 'object', required: true, description: 'Content context including topic, industry, content_type', attributes: [
|
||||||
|
{ name: 'topic', type: 'string', required: true, description: 'Content topic' },
|
||||||
|
{ name: 'industry', type: 'string', required: true, description: 'Content industry' },
|
||||||
|
{ name: 'content_type', type: 'string', required: true, description: 'Type of content (post, article, carousel)' },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
handler: async (args: any) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/linkedin/edit-image', {
|
||||||
|
image_id: args.image_id,
|
||||||
|
prompt: args.prompt,
|
||||||
|
content_context: args.content_context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
image_data: result.image_data,
|
||||||
|
image_id: result.image_id,
|
||||||
|
image_url: result.image_url,
|
||||||
|
message: result.image_id
|
||||||
|
? `✅ LinkedIn image edited successfully! Your edited image (ID: ${result.image_id}) is ready to use.`
|
||||||
|
: `✅ LinkedIn image edited successfully! The image is ready to use in your content.`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Image editing failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error editing image:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to edit image. Please try again.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// LinkedIn Post Generation
|
// LinkedIn Post Generation
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInPost',
|
name: 'generateLinkedInPost',
|
||||||
@@ -468,7 +520,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'topic', type: 'string', required: false },
|
{ name: 'topic', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'slide_count', type: 'number', required: false }
|
{ name: 'number_of_slides', type: 'number', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const prefs = readPrefs();
|
const prefs = readPrefs();
|
||||||
@@ -499,7 +551,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
const res = await linkedInWriterApi.generateCarousel({
|
const res = await linkedInWriterApi.generateCarousel({
|
||||||
topic: args?.topic || prefs.topic || 'Professional development tips',
|
topic: args?.topic || prefs.topic || 'Professional development tips',
|
||||||
industry: mapIndustry(args?.industry || prefs.industry),
|
industry: mapIndustry(args?.industry || prefs.industry),
|
||||||
slide_count: args?.slide_count || prefs.slide_count || 8,
|
number_of_slides: args?.number_of_slides || prefs.number_of_slides || 8,
|
||||||
tone: mapTone(args?.tone || prefs.tone),
|
tone: mapTone(args?.tone || prefs.tone),
|
||||||
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
target_audience: args?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||||
key_takeaways: args?.key_takeaways || prefs.key_takeaways || [],
|
key_takeaways: args?.key_takeaways || prefs.key_takeaways || [],
|
||||||
@@ -898,7 +950,7 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LinkedIn Profile Optimization
|
// LinkedIn Profile Optimization (Coming Soon)
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'optimizeLinkedInProfile',
|
name: 'optimizeLinkedInProfile',
|
||||||
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
description: 'Optimize LinkedIn profile sections for better professional visibility',
|
||||||
@@ -907,29 +959,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'experience_level', type: 'string', required: false }
|
{ name: 'experience_level', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.optimizeProfile({
|
showToastNotification('LinkedIn Profile Optimization is coming soon! Stay tuned for this feature.', 'info');
|
||||||
current_headline: args?.current_headline || 'Professional',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
industry: mapIndustry(args?.industry),
|
|
||||||
experience_level: args?.experience_level || 'mid-level',
|
|
||||||
target_role: args?.target_role,
|
|
||||||
key_skills: args?.key_skills || []
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.success && res.data) {
|
|
||||||
let content = `# LinkedIn Profile Optimization\n\n`;
|
|
||||||
content += `## Optimized Headline\n${res.data.headline}\n\n`;
|
|
||||||
content += `## About Section\n${res.data.about}\n\n`;
|
|
||||||
content += `## Key Skills\n${res.data.skills?.join(', ')}\n\n`;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
|
||||||
return { success: true, content };
|
|
||||||
}
|
|
||||||
return { success: false, message: res.error || 'Failed to optimize LinkedIn profile' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LinkedIn Poll Generation
|
// LinkedIn Poll Generation (Coming Soon)
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInPoll',
|
name: 'generateLinkedInPoll',
|
||||||
description: 'Generate an engaging LinkedIn poll with professional questions',
|
description: 'Generate an engaging LinkedIn poll with professional questions',
|
||||||
@@ -938,31 +974,13 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'poll_type', type: 'string', required: false }
|
{ name: 'poll_type', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.generatePoll({
|
showToastNotification('LinkedIn Poll Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||||
topic: args?.topic || 'Professional development',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
industry: mapIndustry(args?.industry),
|
|
||||||
poll_type: args?.poll_type || 'professional',
|
|
||||||
target_audience: args?.target_audience || 'Industry professionals',
|
|
||||||
question_count: args?.question_count || 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.success && res.data) {
|
|
||||||
let content = `# LinkedIn Poll: ${res.data.question}\n\n`;
|
|
||||||
content += `## Options\n`;
|
|
||||||
res.data.options?.forEach((option: string, index: number) => {
|
|
||||||
content += `${index + 1}. ${option}\n`;
|
|
||||||
});
|
|
||||||
content += `\n## Context\n${res.data.context || ''}\n\n`;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
|
||||||
return { success: true, content };
|
|
||||||
}
|
|
||||||
return { success: false, message: res.error || 'Failed to generate LinkedIn poll' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LinkedIn Company Update Generation
|
// LinkedIn Company Update Generation (Coming Soon)
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generateLinkedInCompanyUpdate',
|
name: 'generateLinkedInCompanyUpdate',
|
||||||
description: 'Generate a professional company update for LinkedIn',
|
description: 'Generate a professional company update for LinkedIn',
|
||||||
@@ -971,22 +989,9 @@ const RegisterLinkedInActions: React.FC = () => {
|
|||||||
{ name: 'update_type', type: 'string', required: false },
|
{ name: 'update_type', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async () => {
|
||||||
const res = await linkedInWriterApi.generateCompanyUpdate({
|
showToastNotification('LinkedIn Company Update Generation is coming soon! Stay tuned for this feature.', 'info');
|
||||||
company_name: args?.company_name || 'Your Company',
|
return { success: false, message: 'Feature coming soon' };
|
||||||
update_type: args?.update_type || 'achievement',
|
|
||||||
industry: mapIndustry(args?.industry),
|
|
||||||
announcement: args?.announcement,
|
|
||||||
target_audience: args?.target_audience || 'Industry professionals and clients',
|
|
||||||
include_metrics: args?.include_metrics ?? true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.success && res.data) {
|
|
||||||
const content = res.data.content;
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
|
||||||
return { success: true, content };
|
|
||||||
}
|
|
||||||
return { success: false, message: res.error || 'Failed to generate LinkedIn company update' };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
|
||||||
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
import { linkedInWriterApi, GroundingLevel } from '../../services/linkedInWriterApi';
|
||||||
import {
|
import {
|
||||||
mapPostType,
|
mapPostType,
|
||||||
@@ -9,8 +8,7 @@ import {
|
|||||||
readPrefs
|
readPrefs
|
||||||
} from './utils/linkedInWriterUtils';
|
} from './utils/linkedInWriterUtils';
|
||||||
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
|
||||||
|
|
||||||
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
||||||
// Get persona context for enhanced content generation
|
// Get persona context for enhanced content generation
|
||||||
@@ -102,9 +100,8 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply persona constraints to parameters
|
// Apply persona constraints to parameters
|
||||||
const personaConstraints = platformPersona?.content_format_rules as any || {};
|
const maxLength = platformPersona?.content_format_rules?.character_limit || prefs.max_length || 2000;
|
||||||
const maxLength = personaConstraints.character_limit || prefs.max_length || 2000;
|
const optimalLength = platformPersona?.content_format_rules?.optimal_length || '150-300 words';
|
||||||
const optimalLength = personaConstraints.optimal_length || '150-300 words';
|
|
||||||
|
|
||||||
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
console.log(`🎭 Persona constraints applied: Max ${maxLength} chars, Optimal: ${optimalLength}`);
|
||||||
|
|
||||||
@@ -329,8 +326,10 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Continue with article generation...
|
// Complete progress and end loading
|
||||||
// (Implementation would continue similar to the post generation)
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Article generation placeholder' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -373,7 +372,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
},
|
},
|
||||||
platform_compliance: {
|
platform_compliance: {
|
||||||
character_count: content.length,
|
character_count: content.length,
|
||||||
optimal_range: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
optimal_range: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||||
status: 'analyzing',
|
status: 'analyzing',
|
||||||
suggestions: [] as string[]
|
suggestions: [] as string[]
|
||||||
}
|
}
|
||||||
@@ -401,7 +400,7 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Platform compliance check
|
// Platform compliance check
|
||||||
const charLimit = (platformPersona.content_format_rules as any)?.character_limit || 3000;
|
const charLimit = platformPersona.content_format_rules?.character_limit || 3000;
|
||||||
if (content.length > charLimit) {
|
if (content.length > charLimit) {
|
||||||
validation.platform_compliance.status = 'exceeds_limit';
|
validation.platform_compliance.status = 'exceeds_limit';
|
||||||
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
validation.platform_compliance.suggestions = [`Content exceeds ${charLimit} character limit by ${content.length - charLimit} characters`];
|
||||||
@@ -445,13 +444,13 @@ const RegisterLinkedInActionsEnhanced: React.FC = () => {
|
|||||||
const suggestions = {
|
const suggestions = {
|
||||||
writing_style: {
|
writing_style: {
|
||||||
sentence_structure: corePersona.linguistic_fingerprint?.sentence_metrics?.preferred_sentence_type || 'balanced',
|
sentence_structure: corePersona.linguistic_fingerprint?.sentence_metrics?.preferred_sentence_type || 'balanced',
|
||||||
tone_recommendation: (corePersona as any).tonal_range?.default_tone || 'professional_friendly',
|
tone_recommendation: platformPersona?.tonal_range?.default_tone || 'professional_friendly',
|
||||||
vocabulary_level: corePersona.linguistic_fingerprint?.lexical_features?.vocabulary_level || 'professional'
|
vocabulary_level: corePersona.linguistic_fingerprint?.lexical_features?.vocabulary_level || 'professional'
|
||||||
},
|
},
|
||||||
platform_optimization: {
|
platform_optimization: {
|
||||||
character_limit: (platformPersona.content_format_rules as any)?.character_limit || 3000,
|
character_limit: platformPersona.content_format_rules?.character_limit || 3000,
|
||||||
optimal_length: (platformPersona.content_format_rules as any)?.optimal_length || '150-300 words',
|
optimal_length: platformPersona.content_format_rules?.optimal_length || '150-300 words',
|
||||||
hashtag_strategy: (platformPersona.lexical_features as any)?.hashtag_strategy || '3-5 relevant hashtags'
|
hashtag_strategy: platformPersona.lexical_features?.hashtag_strategy || '3-5 relevant hashtags'
|
||||||
},
|
},
|
||||||
persona_specific: {
|
persona_specific: {
|
||||||
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
go_to_words: corePersona.linguistic_fingerprint?.lexical_features?.go_to_words || [],
|
||||||
|
|||||||
@@ -1,156 +1,216 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
import { showToastNotification } from '../../utils/toastNotifications';
|
||||||
|
import { linkedInWriterApi } from '../../services/linkedInWriterApi';
|
||||||
|
import { useCopilotActionTyped } from '../../hooks/useCopilotActionTyped';
|
||||||
|
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
function extractHashtags(text: string): string[] {
|
||||||
|
return text.match(/#[A-Za-z0-9_]+/g) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHashtags(text: string): string {
|
||||||
|
return text.replace(/#[A-Za-z0-9_]+\s*/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
const RegisterLinkedInEditActions: React.FC = () => {
|
const RegisterLinkedInEditActions: React.FC = () => {
|
||||||
// Professionalize Content
|
// ── 1. Professionalize ────────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'professionalizeLinkedInContent',
|
name: 'professionalizeLinkedInContent',
|
||||||
description: 'Make LinkedIn content more professional and industry-appropriate',
|
description: 'Make LinkedIn content more professional, polished, and industry-appropriate using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false },
|
{ name: 'industry', type: 'string', required: false },
|
||||||
{ name: 'target_audience', type: 'string', required: false }
|
{ name: 'target_audience', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
// This would integrate with a backend endpoint for content professionalization
|
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const industry = args?.industry || 'Technology';
|
if (!content.trim()) return { success: false, message: 'No content to professionalize' };
|
||||||
const targetAudience = args?.target_audience || 'Professionals';
|
|
||||||
|
|
||||||
// For now, return a placeholder response
|
const res = await linkedInWriterApi.editContent({
|
||||||
const professionalizedContent = `[Professionalized version of your content for ${industry} industry targeting ${targetAudience}]\n\n${content}`;
|
content,
|
||||||
|
edit_type: 'professionalize',
|
||||||
|
industry: args?.industry,
|
||||||
|
target_audience: args?.target_audience,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: professionalizedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: professionalizedContent };
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: 'Content professionalized with AI.' };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to professionalize content' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimize for Engagement
|
// ── 2. Optimize Engagement ────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'optimizeLinkedInEngagement',
|
name: 'optimizeLinkedInEngagement',
|
||||||
description: 'Optimize LinkedIn content for better engagement and reach',
|
description: 'Optimize LinkedIn content for better engagement — strengthen hook, improve readability, encourage interaction',
|
||||||
parameters: [
|
|
||||||
{ name: 'content', type: 'string', required: false },
|
|
||||||
{ name: 'content_type', type: 'string', required: false }
|
|
||||||
],
|
|
||||||
handler: async (args: any) => {
|
|
||||||
const content = args?.content || '';
|
|
||||||
const contentType = args?.content_type || 'post';
|
|
||||||
|
|
||||||
// Placeholder for engagement optimization
|
|
||||||
const optimizedContent = `[Engagement-optimized ${contentType}]\n\n${content}\n\n#ProfessionalDevelopment #Networking #IndustryInsights`;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: optimizedContent } }));
|
|
||||||
return { success: true, content: optimizedContent };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Professional Hashtags
|
|
||||||
useCopilotActionTyped({
|
|
||||||
name: 'addLinkedInHashtags',
|
|
||||||
description: 'Add relevant professional hashtags to LinkedIn content',
|
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'industry', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to optimize' };
|
||||||
|
|
||||||
// Placeholder for hashtag addition
|
const res = await linkedInWriterApi.editContent({
|
||||||
const hashtags = '#ProfessionalDevelopment #Networking #IndustryInsights #CareerGrowth';
|
content,
|
||||||
const contentWithHashtags = `${content}\n\n${hashtags}`;
|
edit_type: 'optimize_engagement',
|
||||||
|
industry: args?.industry,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithHashtags } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: contentWithHashtags };
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: 'Content optimized for engagement.' };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to optimize content' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adjust Tone
|
// ── 3. Add Hashtags (AI-powered) ──────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'adjustLinkedInTone',
|
name: 'addLinkedInHashtags',
|
||||||
description: 'Adjust the tone of LinkedIn content to be more professional, conversational, or authoritative',
|
description: 'Generate relevant, industry-specific hashtags for LinkedIn content using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'target_tone', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false }
|
||||||
|
],
|
||||||
|
handler: async (args: any) => {
|
||||||
|
const content = args?.content || '';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to add hashtags to' };
|
||||||
|
|
||||||
|
const existingHashtags = extractHashtags(content);
|
||||||
|
if (existingHashtags.length >= 5) {
|
||||||
|
showToastNotification('Content already has plenty of hashtags.', 'info');
|
||||||
|
return { success: false, message: 'Content already has 5+ hashtags' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await linkedInWriterApi.editContent({
|
||||||
|
content: stripHashtags(content),
|
||||||
|
edit_type: 'add_hashtags',
|
||||||
|
industry: args?.industry,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success && res.content) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
const newHashtags = extractHashtags(res.content);
|
||||||
|
return { success: true, content: res.content, hashtags: newHashtags };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to generate hashtags' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Adjust Tone ────────────────────────────────────────────────────
|
||||||
|
useCopilotActionTyped({
|
||||||
|
name: 'adjustLinkedInTone',
|
||||||
|
description: 'Rewrite LinkedIn content in a different tone — professional, conversational, authoritative, educational, or friendly',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'content', type: 'string', required: false },
|
||||||
|
{ name: 'target_tone', type: 'string', required: false, description: 'professional, conversational, authoritative, educational, friendly' }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const targetTone = args?.target_tone || 'professional';
|
const targetTone = args?.target_tone || 'professional';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to adjust tone for' };
|
||||||
|
|
||||||
// Placeholder for tone adjustment
|
const res = await linkedInWriterApi.editContent({
|
||||||
const adjustedContent = `[Content adjusted to ${targetTone} tone]\n\n${content}`;
|
content,
|
||||||
|
edit_type: 'adjust_tone',
|
||||||
|
tone: targetTone,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: adjustedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: adjustedContent };
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: `Tone adjusted to ${targetTone}.` };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to adjust tone' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expand Content
|
// ── 5. Expand Content ─────────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'expandLinkedInContent',
|
name: 'expandLinkedInContent',
|
||||||
description: 'Expand LinkedIn content with more details and insights',
|
description: 'Expand LinkedIn content with more depth, examples, data points, and actionable insights using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'expansion_type', type: 'string', required: false }
|
{ name: 'industry', type: 'string', required: false },
|
||||||
|
{ name: 'target_audience', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const expansionType = args?.expansion_type || 'insights';
|
if (!content.trim()) return { success: false, message: 'No content to expand' };
|
||||||
|
|
||||||
// Placeholder for content expansion
|
const res = await linkedInWriterApi.editContent({
|
||||||
const expandedContent = `${content}\n\n[Additional ${expansionType} and context added here]`;
|
content,
|
||||||
|
edit_type: 'expand',
|
||||||
|
industry: args?.industry,
|
||||||
|
target_audience: args?.target_audience,
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: expandedContent } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: expandedContent };
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: 'Content expanded with AI.' };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to expand content' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Condense Content
|
// ── 6. Condense Content ───────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'condenseLinkedInContent',
|
name: 'condenseLinkedInContent',
|
||||||
description: 'Condense LinkedIn content to be more concise and impactful',
|
description: 'Condense LinkedIn content to be more concise and impactful using AI — preserves key messages',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'target_length', type: 'string', required: false }
|
{ name: 'target_length', type: 'string', required: false, description: 'short, medium, long' }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const targetLength = args?.target_length || 'short';
|
const targetLength = args?.target_length || 'medium';
|
||||||
|
if (!content.trim()) return { success: false, message: 'No content to condense' };
|
||||||
|
|
||||||
// Placeholder for content condensation
|
const lengthMap: Record<string, string> = { short: 'very concise (1-2 sentences)', medium: 'half the original length', long: 'slightly shortened' };
|
||||||
const condensedContent = `[Condensed to ${targetLength} format]\n\n${content.substring(0, Math.min(content.length, 500))}...`;
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: condensedContent } }));
|
const res = await linkedInWriterApi.editContent({
|
||||||
return { success: true, content: condensedContent };
|
content,
|
||||||
|
edit_type: 'condense',
|
||||||
|
parameters: { target_length: lengthMap[targetLength] || lengthMap.medium },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success && res.content) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: 'Content condensed with AI.' };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to condense content' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Call to Action
|
// ── 7. Add Call to Action ─────────────────────────────────────────────
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'addLinkedInCallToAction',
|
name: 'addLinkedInCallToAction',
|
||||||
description: 'Add a professional call to action to LinkedIn content',
|
description: 'Add a contextual, engaging call-to-action to LinkedIn content using AI',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'content', type: 'string', required: false },
|
{ name: 'content', type: 'string', required: false },
|
||||||
{ name: 'cta_type', type: 'string', required: false }
|
{ name: 'cta_type', type: 'string', required: false, description: 'engagement, networking, learning, collaboration' }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
const content = args?.content || '';
|
const content = args?.content || '';
|
||||||
const ctaType = args?.cta_type || 'engagement';
|
if (!content.trim()) return { success: false, message: 'No content to add CTA to' };
|
||||||
|
|
||||||
const ctaOptions = {
|
if (/\b(call now|sign up|join|try|learn more|comment|share|connect|message|dm|reach out)\b/i.test(content)) {
|
||||||
engagement: 'What are your thoughts on this? Share your experience in the comments below!',
|
showToastNotification('Content already contains a call to action.', 'info');
|
||||||
networking: 'Let\'s connect if you\'re interested in discussing this further.',
|
return { success: false, message: 'Content already has a CTA' };
|
||||||
learning: 'Would you like to learn more about this topic? Drop a comment or DM me.',
|
}
|
||||||
collaboration: 'Interested in collaborating on similar projects? Let\'s connect!'
|
|
||||||
};
|
|
||||||
|
|
||||||
const cta = ctaOptions[ctaType as keyof typeof ctaOptions] || ctaOptions.engagement;
|
const res = await linkedInWriterApi.editContent({
|
||||||
const contentWithCTA = `${content}\n\n${cta}`;
|
content,
|
||||||
|
edit_type: 'add_cta',
|
||||||
|
parameters: { cta_type: args?.cta_type || 'engagement' },
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: contentWithCTA } }));
|
if (res.success && res.content) {
|
||||||
return { success: true, content: contentWithCTA };
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target: res.content } }));
|
||||||
|
return { success: true, content: res.content, message: 'CTA added with AI.' };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.error || 'Failed to add CTA' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
import { apiClient } from '../../../api/client';
|
import { apiClient } from '../../../api/client';
|
||||||
|
import '../../../types/linkedinWriterEvents';
|
||||||
|
|
||||||
// Define the cache data type
|
// Define the cache data type
|
||||||
interface BrainstormCacheData {
|
interface BrainstormCacheData {
|
||||||
@@ -118,7 +119,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
const handler = async (ev: any) => {
|
const handler = async (ev: any) => {
|
||||||
try {
|
try {
|
||||||
// Store the event for refresh functionality
|
// Store the event for refresh functionality
|
||||||
(window as any).lastBrainstormEvent = ev;
|
window.lastBrainstormEvent = ev;
|
||||||
|
|
||||||
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
||||||
const finalSeed = ideaSeed || prompt;
|
const finalSeed = ideaSeed || prompt;
|
||||||
@@ -239,8 +240,8 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
setBrainstormVisible(false);
|
setBrainstormVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||||
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas', handler);
|
||||||
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,7 +276,7 @@ const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Force refresh by clearing cache and re-running
|
// Force refresh by clearing cache and re-running
|
||||||
const { prompt, seed: ideaSeed } = (window as any).lastBrainstormEvent?.detail || {};
|
const { prompt, seed: ideaSeed } = window.lastBrainstormEvent?.detail || {};
|
||||||
if (prompt || ideaSeed) {
|
if (prompt || ideaSeed) {
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||||
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
target_audience: args.target_audience ?? prefs.target_audience ?? '',
|
target_audience: args.target_audience ?? prefs.target_audience ?? '',
|
||||||
tone: args.tone ?? prefs.tone ?? 'professional',
|
tone: args.tone ?? prefs.tone ?? 'professional',
|
||||||
industry: args.industry ?? prefs.industry ?? 'technology',
|
industry: args.industry ?? prefs.industry ?? 'technology',
|
||||||
slide_count: args.slide_count ?? (prefs.slide_count ?? 5),
|
number_of_slides: args.number_of_slides ?? (prefs.number_of_slides ?? 5),
|
||||||
key_takeaways: args.key_takeaways ?? (prefs.key_takeaways ?? []),
|
key_takeaways: args.key_takeaways ?? (prefs.key_takeaways ?? []),
|
||||||
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
include_cover_slide: args.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||||
include_cta_slide: args.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
include_cta_slide: args.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||||
@@ -40,7 +40,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
detail: {
|
detail: {
|
||||||
action: 'Generating LinkedIn Carousel',
|
action: 'Generating LinkedIn Carousel',
|
||||||
message: `Creating a ${form.slide_count}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
message: `Creating a ${form.number_of_slides}-slide LinkedIn carousel about "${form.topic}". This visual content will engage your ${form.target_audience} with a ${form.visual_style} design approach.`
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
target_audience: form.target_audience,
|
target_audience: form.target_audience,
|
||||||
tone: mapTone(form.tone),
|
tone: mapTone(form.tone),
|
||||||
industry: mapIndustry(form.industry),
|
industry: mapIndustry(form.industry),
|
||||||
slide_count: form.slide_count,
|
number_of_slides: form.number_of_slides,
|
||||||
key_takeaways: form.key_takeaways,
|
key_takeaways: form.key_takeaways,
|
||||||
include_cover_slide: form.include_cover_slide,
|
include_cover_slide: form.include_cover_slide,
|
||||||
include_cta_slide: form.include_cta_slide,
|
include_cta_slide: form.include_cta_slide,
|
||||||
@@ -73,7 +73,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
tone: form.tone,
|
tone: form.tone,
|
||||||
industry: form.industry,
|
industry: form.industry,
|
||||||
target_audience: form.target_audience,
|
target_audience: form.target_audience,
|
||||||
slide_count: form.slide_count,
|
number_of_slides: form.number_of_slides,
|
||||||
key_takeaways: form.key_takeaways,
|
key_takeaways: form.key_takeaways,
|
||||||
include_cover_slide: form.include_cover_slide,
|
include_cover_slide: form.include_cover_slide,
|
||||||
include_cta_slide: form.include_cta_slide,
|
include_cta_slide: form.include_cta_slide,
|
||||||
@@ -100,7 +100,7 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
success: true,
|
success: true,
|
||||||
carousel_content: content,
|
carousel_content: content,
|
||||||
title: res.data.title,
|
title: res.data.title,
|
||||||
slide_count: res.data.slides.length
|
number_of_slides: res.data.slides.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No data received from API');
|
throw new Error('No data received from API');
|
||||||
@@ -183,11 +183,11 @@ const CarouselHITL: React.FC<CarouselHITLProps> = ({ args, respond }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="slide_count">Number of Slides</label>
|
<label htmlFor="number_of_slides">Number of Slides</label>
|
||||||
<select
|
<select
|
||||||
id="slide_count"
|
id="number_of_slides"
|
||||||
value={form.slide_count}
|
value={form.number_of_slides}
|
||||||
onChange={(e) => setForm({ ...form, slide_count: parseInt(e.target.value) })}
|
onChange={(e) => setForm({ ...form, number_of_slides: parseInt(e.target.value) })}
|
||||||
>
|
>
|
||||||
<option value={3}>3 slides (Quick overview)</option>
|
<option value={3}>3 slides (Quick overview)</option>
|
||||||
<option value={5}>5 slides (Standard)</option>
|
<option value={5}>5 slides (Standard)</option>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
import { useCopilotContext } from '@copilotkit/react-core';
|
||||||
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import { showToastNotification } from '../../../utils/toastNotifications';
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||||
|
import '../../../types/linkedinWriterEvents';
|
||||||
|
|
||||||
// Optional debug flag: set to true to enable verbose logs locally
|
// Optional debug flag: set to true to enable verbose logs locally
|
||||||
const DEBUG_LINKEDIN = false;
|
const DEBUG_LINKEDIN = false;
|
||||||
@@ -66,9 +67,9 @@ export const useCopilotActions = ({
|
|||||||
if (copilotContext && typeof copilotContext === 'object') {
|
if (copilotContext && typeof copilotContext === 'object') {
|
||||||
try {
|
try {
|
||||||
// Check if context has any message sending capabilities
|
// Check if context has any message sending capabilities
|
||||||
if ('sendMessage' in copilotContext && typeof copilotContext.sendMessage === 'function') {
|
if ('sendMessage' in copilotContext && typeof (copilotContext as Record<string, unknown>).sendMessage === 'function') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
(copilotContext as any).sendMessage(prompt);
|
(copilotContext as { sendMessage: (msg: string) => void }).sendMessage(prompt);
|
||||||
console.log('Message sent via context');
|
console.log('Message sent via context');
|
||||||
return;
|
return;
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -85,7 +86,7 @@ export const useCopilotActions = ({
|
|||||||
document.querySelector('button[title*="generateFromPrompt"]');
|
document.querySelector('button[title*="generateFromPrompt"]');
|
||||||
if (actionButton) {
|
if (actionButton) {
|
||||||
// Set the prompt in a temporary storage for the action to pick up
|
// Set the prompt in a temporary storage for the action to pick up
|
||||||
(window as any).tempPromptForGeneration = prompt;
|
window.tempPromptForGeneration = prompt;
|
||||||
(actionButton as HTMLElement).click();
|
(actionButton as HTMLElement).click();
|
||||||
console.log('Triggered generateFromPrompt action with:', prompt);
|
console.log('Triggered generateFromPrompt action with:', prompt);
|
||||||
return;
|
return;
|
||||||
@@ -235,8 +236,8 @@ export const useCopilotActions = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
window.addEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||||
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Allow external prompts to trigger content generation
|
// Allow external prompts to trigger content generation
|
||||||
@@ -248,15 +249,15 @@ export const useCopilotActions = ({
|
|||||||
],
|
],
|
||||||
handler: async ({ prompt }: { prompt: string }) => {
|
handler: async ({ prompt }: { prompt: string }) => {
|
||||||
// Check for temporary prompt from brainstorm flow
|
// Check for temporary prompt from brainstorm flow
|
||||||
const finalPrompt = prompt || (window as any).tempPromptForGeneration;
|
const finalPrompt = prompt || window.tempPromptForGeneration;
|
||||||
|
|
||||||
if (!finalPrompt) {
|
if (!finalPrompt) {
|
||||||
return { success: false, message: 'No prompt provided' };
|
return { success: false, message: 'No prompt provided' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the temporary prompt
|
// Clear the temporary prompt
|
||||||
if ((window as any).tempPromptForGeneration) {
|
if (window.tempPromptForGeneration) {
|
||||||
delete (window as any).tempPromptForGeneration;
|
delete window.tempPromptForGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the prompt as context and trigger generation
|
// Set the prompt as context and trigger generation
|
||||||
@@ -281,9 +282,21 @@ export const useCopilotActions = ({
|
|||||||
name: 'editLinkedInDraft',
|
name: 'editLinkedInDraft',
|
||||||
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen'] }
|
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen', 'AddEmojis', 'AddHashtags', 'ImproveClarity', 'AdjustTone', 'RewriteHook'] }
|
||||||
],
|
],
|
||||||
handler: async ({ operation }: { operation: string }) => {
|
handler: async ({ operation }: { operation: string }) => {
|
||||||
|
const COMING_SOON_OPS = ['ImproveClarity', 'AdjustTone', 'RewriteHook'];
|
||||||
|
if (COMING_SOON_OPS.includes(operation)) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ImproveClarity: 'Improve Clarity',
|
||||||
|
AdjustTone: 'Tone Adjustment',
|
||||||
|
RewriteHook: 'Hook Rewrite'
|
||||||
|
};
|
||||||
|
const label = labels[operation] || operation;
|
||||||
|
showToastNotification(`${label} is coming soon! This feature will use AI to enhance your content.`, 'info');
|
||||||
|
return { success: false, message: `${label} feature coming soon` };
|
||||||
|
}
|
||||||
|
|
||||||
const currentDraft = draft || '';
|
const currentDraft = draft || '';
|
||||||
if (!currentDraft) {
|
if (!currentDraft) {
|
||||||
return { success: false, message: 'No draft content to edit' };
|
return { success: false, message: 'No draft content to edit' };
|
||||||
@@ -336,6 +349,49 @@ export const useCopilotActions = ({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'AddEmojis': {
|
||||||
|
const selectEmojis = (text: string): string[] => {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let category = 'general';
|
||||||
|
if (/achiev|success|milestone|goal|win/i.test(lower)) category = 'achievement';
|
||||||
|
else if (/strateg|plan|growth|metric/i.test(lower)) category = 'strategy';
|
||||||
|
else if (/collabor|team|partner|connect|network/i.test(lower)) category = 'collaboration';
|
||||||
|
else if (/learn|skill|develop|train|educat/i.test(lower)) category = 'learning';
|
||||||
|
else if (/innovat|new|future|transform|ai|tech/i.test(lower)) category = 'innovation';
|
||||||
|
const emojiSets: Record<string, string[]> = {
|
||||||
|
achievement: ['🏆', '🎯', '⭐', '🚀', '💪'],
|
||||||
|
strategy: ['📈', '📊', '🧭', '💡', '🔑'],
|
||||||
|
collaboration: ['🤝', '👥', '💬', '🌐', '🤝'],
|
||||||
|
learning: ['📚', '🎓', '🧠', '💡', '📖'],
|
||||||
|
innovation: ['💡', '🔬', '⚡', '🔮', '✨'],
|
||||||
|
general: ['✅', '🎯', '💡', '📌', '🔥']
|
||||||
|
};
|
||||||
|
return emojiSets[category];
|
||||||
|
};
|
||||||
|
const emojis = selectEmojis(currentDraft);
|
||||||
|
const enriched = currentDraft.split('\n').map((line: string, i: number) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('---') || trimmed.startsWith('http')) return line;
|
||||||
|
return `${emojis[i % emojis.length]} ${line}`;
|
||||||
|
}).join('\n');
|
||||||
|
editedContent = enriched;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'AddHashtags': {
|
||||||
|
const INDUSTRY_TAGS: Record<string, string[]> = {
|
||||||
|
technology: ['#TechLeadership', '#DigitalTransformation', '#Innovation', '#FutureOfWork', '#AI'],
|
||||||
|
marketing: ['#MarketingStrategy', '#DigitalMarketing', '#ContentMarketing', '#GrowthMarketing', '#BrandBuilding'],
|
||||||
|
default: ['#ProfessionalDevelopment', '#CareerGrowth', '#Leadership', '#IndustryInsights', '#Networking']
|
||||||
|
};
|
||||||
|
const existing: string[] = currentDraft.match(/#[A-Za-z0-9_]+/g) || [];
|
||||||
|
if (existing.length >= 5) break;
|
||||||
|
const tags = (INDUSTRY_TAGS[userPreferences?.industry?.toLowerCase()] || INDUSTRY_TAGS.default)
|
||||||
|
.filter((t: string) => !existing.includes(t)).slice(0, 5);
|
||||||
|
if (tags.length > 0) editedContent = `${currentDraft}\n\n${tags.join(' ')}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { success: false, message: 'Unknown operation' };
|
return { success: false, message: 'Unknown operation' };
|
||||||
}
|
}
|
||||||
@@ -355,34 +411,57 @@ export const useCopilotActions = ({
|
|||||||
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
|
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
|
||||||
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
||||||
const isLong = (draft || '').length > 500;
|
const isLong = (draft || '').length > 500;
|
||||||
|
const hasPersona = !!(corePersona && platformPersona);
|
||||||
|
|
||||||
// Debug logging for suggestions
|
// Debug logging for suggestions
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
||||||
hasContent,
|
hasContent,
|
||||||
justGeneratedContent,
|
justGeneratedContent,
|
||||||
|
hasPersona,
|
||||||
draftLength: draft?.length || 0
|
draftLength: draft?.length || 0
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasContent) {
|
if (!hasContent) {
|
||||||
// Initial suggestions for content creation
|
// Initial suggestions for content creation
|
||||||
const initialSuggestions = [
|
const initialSuggestions: { title: string; message: string }[] = [];
|
||||||
|
|
||||||
|
// Persona-aware actions first when persona data is available
|
||||||
|
if (hasPersona) {
|
||||||
|
initialSuggestions.push(
|
||||||
|
{ title: '🎭 Post (Persona-Optimized)', message: 'Use tool generateLinkedInPostWithPersona to create a post optimized for your writing style and LinkedIn platform constraints.' },
|
||||||
|
{ title: '🎭 Article (Persona-Optimized)', message: 'Use tool generateLinkedInArticleWithPersona to write an article with persona-aware optimization.' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard actions
|
||||||
|
initialSuggestions.push(
|
||||||
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
||||||
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
||||||
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
||||||
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
||||||
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
||||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' },
|
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' }
|
||||||
{ title: '🎨 Visual Content', message: 'Create engaging visual content with AI-generated images optimized for LinkedIn.' }
|
);
|
||||||
];
|
|
||||||
|
// Persona validation and suggestions when persona is available
|
||||||
|
if (hasPersona) {
|
||||||
|
initialSuggestions.push(
|
||||||
|
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check existing content against your writing persona.' },
|
||||||
|
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations based on your persona.' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
||||||
return initialSuggestions;
|
return initialSuggestions;
|
||||||
} else {
|
} else {
|
||||||
// Refinement suggestions for existing content - use direct edit actions
|
// Refinement suggestions for existing content - use direct edit actions
|
||||||
const refinementSuggestions = [
|
const refinementSuggestions: { title: string; message: string }[] = [
|
||||||
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
||||||
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
||||||
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
||||||
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
||||||
|
{ title: '😀 Add emojis', message: 'Use tool editLinkedInDraft with operation AddEmojis' },
|
||||||
|
{ title: '🏷️ Add hashtags', message: 'Use tool editLinkedInDraft with operation AddHashtags' },
|
||||||
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
||||||
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
||||||
];
|
];
|
||||||
@@ -413,6 +492,14 @@ export const useCopilotActions = ({
|
|||||||
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persona-aware refinement actions
|
||||||
|
if (hasPersona) {
|
||||||
|
refinementSuggestions.push(
|
||||||
|
{ title: '✅ Validate Against Persona', message: 'Use tool validateContentAgainstPersona to check this content against your writing persona.' },
|
||||||
|
{ title: '🎨 Get Writing Suggestions', message: 'Use tool getPersonaWritingSuggestions to receive personalized recommendations.' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add image generation suggestion when there's content
|
// Add image generation suggestion when there's content
|
||||||
if (draft && draft.trim().length > 0) {
|
if (draft && draft.trim().length > 0) {
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
||||||
@@ -439,7 +526,7 @@ export const useCopilotActions = ({
|
|||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
||||||
return refinementSuggestions;
|
return refinementSuggestions;
|
||||||
}
|
}
|
||||||
}, [draft, justGeneratedContent]);
|
}, [draft, justGeneratedContent, corePersona, platformPersona]);
|
||||||
|
|
||||||
// Return the suggestions function directly
|
// Return the suggestions function directly
|
||||||
return getIntelligentSuggestions;
|
return getIntelligentSuggestions;
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
||||||
if (!useGoogleSearch || words.length < 4) return;
|
if (!useGoogleSearch || words.length < 4) return;
|
||||||
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
||||||
const tone = (corePersona as any)?.tonal_range?.default_tone || (platformPersona as any)?.tonal_range?.default_tone || 'professional';
|
const tone = platformPersona?.tonal_range?.default_tone || 'professional';
|
||||||
const goTo = corePersona?.linguistic_fingerprint?.lexical_features?.go_to_words?.slice(0,5)?.join(', ');
|
const goTo = corePersona?.linguistic_fingerprint?.lexical_features?.go_to_words?.slice(0,5)?.join(', ');
|
||||||
const platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
const platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
||||||
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
|
||||||
import {
|
import {
|
||||||
AutoAwesome as SparklesIcon,
|
AutoAwesome as SparklesIcon,
|
||||||
PhotoCamera as PhotoIcon,
|
PhotoCamera as PhotoIcon,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
CheckCircle as CheckCircleIcon,
|
CheckCircle as CheckCircleIcon,
|
||||||
Warning as ExclamationTriangleIcon
|
Warning as ExclamationTriangleIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { useCopilotActionTyped } from '../../../hooks/useCopilotActionTyped';
|
||||||
|
|
||||||
interface ImageGenerationSuggestionsProps {
|
interface ImageGenerationSuggestionsProps {
|
||||||
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
contentType: 'post' | 'article' | 'carousel' | 'video_script';
|
||||||
@@ -51,9 +51,6 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
|||||||
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
const [prompts, setPrompts] = useState<ImagePrompt[]>([]);
|
||||||
const [showPrompts, setShowPrompts] = useState(false);
|
const [showPrompts, setShowPrompts] = useState(false);
|
||||||
|
|
||||||
// Use the same pattern as other components in the project
|
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
|
||||||
|
|
||||||
// Register Copilot action for generating image prompts
|
// Register Copilot action for generating image prompts
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'generate_image_prompts',
|
name: 'generate_image_prompts',
|
||||||
@@ -119,7 +116,12 @@ const ImageGenerationSuggestions: React.FC<ImageGenerationSuggestionsProps> = ({
|
|||||||
description: 'Generate LinkedIn-optimized image from selected prompt',
|
description: 'Generate LinkedIn-optimized image from selected prompt',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'prompt', type: 'string', required: true },
|
{ name: 'prompt', type: 'string', required: true },
|
||||||
{ name: 'content_context', type: 'object', required: true },
|
{ name: 'content_context', type: 'object', required: true, attributes: [
|
||||||
|
{ name: 'topic', type: 'string', required: true },
|
||||||
|
{ name: 'industry', type: 'string', required: true },
|
||||||
|
{ name: 'content_type', type: 'string', required: true },
|
||||||
|
{ name: 'style', type: 'string', required: true }
|
||||||
|
] },
|
||||||
{ name: 'aspect_ratio', type: 'string', required: false }
|
{ name: 'aspect_ratio', type: 'string', required: false }
|
||||||
],
|
],
|
||||||
handler: async (args: any) => {
|
handler: async (args: any) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
type ProgressStatus = 'pending' | 'active' | 'completed' | 'error';
|
||||||
|
|
||||||
interface ProgressStep {
|
export interface ProgressStep {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: ProgressStatus;
|
status: ProgressStatus;
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||||
|
|
||||||
|
interface QuickCreateProps {
|
||||||
|
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
userPreferences: LinkedInPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentType = 'post' | 'article' | 'carousel' | 'video_script';
|
||||||
|
|
||||||
|
const CONTENT_TYPES: { type: ContentType; label: string; icon: string; description: string; color: string }[] = [
|
||||||
|
{ type: 'post', label: 'Post', icon: '📝', description: 'Professional LinkedIn post with engagement hooks', color: '#0a66c2' },
|
||||||
|
{ type: 'article', label: 'Article', icon: '📄', description: 'Thought leadership article with in-depth analysis', color: '#057642' },
|
||||||
|
{ type: 'carousel', label: 'Carousel', icon: '🎠', description: 'Multi-slide carousel for visual storytelling', color: '#8b5cf6' },
|
||||||
|
{ type: 'video_script', label: 'Video Script', icon: '🎬', description: 'Engaging video script with hook & scenes', color: '#dc2626' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TONES = ['Professional', 'Conversational', 'Authoritative', 'Inspirational', 'Educational', 'Friendly'];
|
||||||
|
const INDUSTRIES = ['Technology', 'Healthcare', 'Finance', 'Education', 'Manufacturing', 'Retail', 'Marketing', 'Consulting', 'Real Estate', 'Legal', 'Non-profit', 'Entertainment', 'Energy', 'Custom'];
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
topic: '',
|
||||||
|
industry: '',
|
||||||
|
tone: '',
|
||||||
|
target_audience: '',
|
||||||
|
key_points: '',
|
||||||
|
post_type: '',
|
||||||
|
word_count: 1500,
|
||||||
|
number_of_slides: 8,
|
||||||
|
video_length: 60,
|
||||||
|
key_takeaways: '',
|
||||||
|
key_messages: '',
|
||||||
|
key_sections: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuickCreate: React.FC<QuickCreateProps> = ({
|
||||||
|
onGeneratePost,
|
||||||
|
onGenerateArticle,
|
||||||
|
onGenerateCarousel,
|
||||||
|
onGenerateVideoScript,
|
||||||
|
userPreferences
|
||||||
|
}) => {
|
||||||
|
const [selectedType, setSelectedType] = useState<ContentType | null>(null);
|
||||||
|
const [formData, setFormData] = useState(defaultForm);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const openModal = (type: ContentType) => {
|
||||||
|
setFormData({
|
||||||
|
...defaultForm,
|
||||||
|
industry: userPreferences?.industry || '',
|
||||||
|
tone: userPreferences?.tone || 'Professional',
|
||||||
|
target_audience: userPreferences?.target_audience || ''
|
||||||
|
});
|
||||||
|
setSelectedType(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setSelectedType(null);
|
||||||
|
setFormData(defaultForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!selectedType || generating) return;
|
||||||
|
setGenerating(true);
|
||||||
|
const params = { ...formData };
|
||||||
|
try {
|
||||||
|
const generators = {
|
||||||
|
post: onGeneratePost,
|
||||||
|
article: onGenerateArticle,
|
||||||
|
carousel: onGenerateCarousel,
|
||||||
|
video_script: onGenerateVideoScript
|
||||||
|
};
|
||||||
|
await generators[selectedType](params);
|
||||||
|
closeModal();
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setField = (field: string, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalContent = useMemo(() => {
|
||||||
|
if (!selectedType) return null;
|
||||||
|
|
||||||
|
const commonFields = (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Topic *</label>
|
||||||
|
<input
|
||||||
|
value={formData.topic}
|
||||||
|
onChange={e => setField('topic', e.target.value)}
|
||||||
|
placeholder={`e.g., ${selectedType === 'video_script' ? 'Networking tips' : 'AI trends in ' + (formData.industry || 'Technology')}`}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Industry</label>
|
||||||
|
<select
|
||||||
|
value={formData.industry}
|
||||||
|
onChange={e => setField('industry', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||||
|
>
|
||||||
|
{INDUSTRIES.map(ind => <option key={ind} value={ind}>{ind}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Tone</label>
|
||||||
|
<select
|
||||||
|
value={formData.tone}
|
||||||
|
onChange={e => setField('tone', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', background: 'white' }}
|
||||||
|
>
|
||||||
|
{TONES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Target Audience</label>
|
||||||
|
<input
|
||||||
|
value={formData.target_audience}
|
||||||
|
onChange={e => setField('target_audience', e.target.value)}
|
||||||
|
placeholder="e.g., Product Managers, CTOs"
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (selectedType) {
|
||||||
|
case 'post':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Post</h3>
|
||||||
|
{commonFields}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Points</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.key_points}
|
||||||
|
onChange={e => setField('key_points', e.target.value)}
|
||||||
|
placeholder="Key point 1 / Key point 2 / Key point 3"
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'article':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Article</h3>
|
||||||
|
{commonFields}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Word Count</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.word_count}
|
||||||
|
onChange={e => setField('word_count', parseInt(e.target.value) || 1500)}
|
||||||
|
min={500}
|
||||||
|
max={5000}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Sections</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.key_sections}
|
||||||
|
onChange={e => setField('key_sections', e.target.value)}
|
||||||
|
placeholder="Introduction / Current challenges / Best practices / Future outlook"
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'carousel':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Carousel</h3>
|
||||||
|
{commonFields}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Number of Slides</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.number_of_slides}
|
||||||
|
onChange={e => setField('number_of_slides', parseInt(e.target.value) || 8)}
|
||||||
|
min={3}
|
||||||
|
max={20}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Takeaways</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.key_takeaways}
|
||||||
|
onChange={e => setField('key_takeaways', e.target.value)}
|
||||||
|
placeholder="Key insight / Important trend / Actionable tip"
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'video_script':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 800, color: '#111827' }}>Generate LinkedIn Video Script</h3>
|
||||||
|
{commonFields}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Video Length (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.video_length}
|
||||||
|
onChange={e => setField('video_length', parseInt(e.target.value) || 60)}
|
||||||
|
min={15}
|
||||||
|
max={600}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' }}>Key Messages</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.key_messages}
|
||||||
|
onChange={e => setField('key_messages', e.target.value)}
|
||||||
|
placeholder="Core message / Practical advice / Call to action"
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 14, outline: 'none', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedType, formData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 12, textAlign: 'center' }}>Quick Create</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
|
||||||
|
{CONTENT_TYPES.map(({ type, label, icon, description, color }) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => openModal(type)}
|
||||||
|
style={{
|
||||||
|
padding: '14px 10px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
textAlign: 'center',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = color;
|
||||||
|
e.currentTarget.style.boxShadow = `0 4px 12px ${color}20`;
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.06)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 28, marginBottom: 6 }}>{icon}</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: '#111827' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 4, lineHeight: '1.3' }}>{description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generation Modal */}
|
||||||
|
{selectedType && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10020, padding: 20 }}>
|
||||||
|
<div style={{ background: 'white', width: 520, maxWidth: '100%', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: 16, borderBottom: '1px solid #e5e7eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 15, color: '#111827' }}>{CONTENT_TYPES.find(c => c.type === selectedType)?.icon} {CONTENT_TYPES.find(c => c.type === selectedType)?.label}</div>
|
||||||
|
<button onClick={closeModal} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#6b7280', padding: '4px 8px', borderRadius: 6 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16, maxHeight: '60vh', overflow: 'auto' }}>
|
||||||
|
{modalContent}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 16px', borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={closeModal} style={{ padding: '10px 20px', border: '1px solid #d1d5db', borderRadius: 8, background: 'white', cursor: 'pointer', fontSize: 14, fontWeight: 600 }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating || !formData.topic.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: generating ? '#9ca3af' : CONTENT_TYPES.find(c => c.type === selectedType)?.color || '#0a66c2',
|
||||||
|
color: 'white',
|
||||||
|
cursor: generating || !formData.topic.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
opacity: generating || !formData.topic.trim() ? 0.7 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating && <div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid white', borderTopColor: 'transparent', animation: 'spin 0.8s linear infinite' }} />}
|
||||||
|
{generating ? 'Generating...' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FeatureCarousel } from './FeatureCarousel';
|
import { FeatureCarousel } from './FeatureCarousel';
|
||||||
import { InfoModals } from './InfoModals';
|
import { InfoModals } from './InfoModals';
|
||||||
|
import { QuickCreate } from './QuickCreate';
|
||||||
|
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||||
|
|
||||||
interface WelcomeMessageProps {
|
interface WelcomeMessageProps {
|
||||||
draft: string;
|
draft: string;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
|
onGeneratePost: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateArticle: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateCarousel: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
onGenerateVideoScript: (params?: any) => Promise<{ success: boolean; data?: any; error?: string }>;
|
||||||
|
userPreferences: LinkedInPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
||||||
draft,
|
draft,
|
||||||
isGenerating
|
isGenerating,
|
||||||
|
onGeneratePost,
|
||||||
|
onGenerateArticle,
|
||||||
|
onGenerateCarousel,
|
||||||
|
onGenerateVideoScript,
|
||||||
|
userPreferences
|
||||||
}) => {
|
}) => {
|
||||||
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
const [showCopilotModal, setShowCopilotModal] = useState(false);
|
||||||
const [showAssistiveModal, setShowAssistiveModal] = useState(false);
|
const [showAssistiveModal, setShowAssistiveModal] = useState(false);
|
||||||
@@ -267,6 +279,17 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({
|
|||||||
Choose your preferred AI assistance mode to get started with content creation.
|
Choose your preferred AI assistance mode to get started with content creation.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Quick Create - Direct generation buttons */}
|
||||||
|
<div style={{ width: '100%', maxWidth: 640, marginBottom: 24 }}>
|
||||||
|
<QuickCreate
|
||||||
|
onGeneratePost={onGeneratePost}
|
||||||
|
onGenerateArticle={onGenerateArticle}
|
||||||
|
onGenerateCarousel={onGenerateCarousel}
|
||||||
|
onGenerateVideoScript={onGenerateVideoScript}
|
||||||
|
userPreferences={userPreferences}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Info Modals */}
|
{/* Info Modals */}
|
||||||
<InfoModals
|
<InfoModals
|
||||||
showCopilotModal={showCopilotModal}
|
showCopilotModal={showCopilotModal}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
export { default as PostHITL } from './PostHITL';
|
|
||||||
export { default as ArticleHITL } from './ArticleHITL';
|
|
||||||
export { default as CarouselHITL } from './CarouselHITL';
|
|
||||||
export { default as VideoScriptHITL } from './VideoScriptHITL';
|
|
||||||
export { default as CommentResponseHITL } from './CommentResponseHITL';
|
|
||||||
|
|
||||||
// New refactored components
|
// New refactored components
|
||||||
export { Header } from './Header';
|
export { Header } from './Header';
|
||||||
export { ContentEditor } from './ContentEditor';
|
export { ContentEditor } from './ContentEditor';
|
||||||
@@ -12,6 +6,7 @@ export { WelcomeMessage } from './WelcomeMessage';
|
|||||||
export { FeatureCarousel } from './FeatureCarousel';
|
export { FeatureCarousel } from './FeatureCarousel';
|
||||||
export { InfoModals } from './InfoModals';
|
export { InfoModals } from './InfoModals';
|
||||||
export { ProgressTracker } from './ProgressTracker';
|
export { ProgressTracker } from './ProgressTracker';
|
||||||
|
export type { ProgressStep } from './ProgressTracker';
|
||||||
export { ContentRecommendations } from './ContentRecommendations';
|
export { ContentRecommendations } from './ContentRecommendations';
|
||||||
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
||||||
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
export { CustomMessageRenderer } from './CustomMessageRenderer';
|
||||||
@@ -27,3 +22,4 @@ export { default as ImageGenerationTest } from './ImageGenerationTest';
|
|||||||
// Refactored Components
|
// Refactored Components
|
||||||
export { default as BrainstormFlow } from './BrainstormFlow';
|
export { default as BrainstormFlow } from './BrainstormFlow';
|
||||||
export { useCopilotActions } from './CopilotActions';
|
export { useCopilotActions } from './CopilotActions';
|
||||||
|
export { QuickCreate } from './QuickCreate';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useCopilotReadable } from '@copilotkit/react-core';
|
|
||||||
import {
|
import {
|
||||||
loadHistory,
|
loadHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
@@ -12,7 +11,8 @@ import {
|
|||||||
type ChatMsg,
|
type ChatMsg,
|
||||||
type LinkedInPreferences
|
type LinkedInPreferences
|
||||||
} from '../utils/storageUtils';
|
} from '../utils/storageUtils';
|
||||||
import { getContextAwareSuggestions } from '../utils/linkedInWriterUtils';
|
import { getContextAwareSuggestions, mapPostType, mapTone, mapIndustry, mapSearchEngine, readPrefs } from '../utils/linkedInWriterUtils';
|
||||||
|
import { linkedInWriterApi, GroundingLevel } from '../../../services/linkedInWriterApi';
|
||||||
|
|
||||||
export function useLinkedInWriter() {
|
export function useLinkedInWriter() {
|
||||||
// Core state
|
// Core state
|
||||||
@@ -51,24 +51,18 @@ export function useLinkedInWriter() {
|
|||||||
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
const [userPreferences, setUserPreferences] = useState<LinkedInPreferences>(getPreferences());
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [currentSuggestions, setCurrentSuggestions] = useState<Array<{title: string, message: string, priority?: string}>>([]);
|
const currentSuggestions = useMemo(() => getContextAwareSuggestions(
|
||||||
|
userPreferences,
|
||||||
|
draft,
|
||||||
|
chatHistory.slice(-5),
|
||||||
|
userPreferences.last_used_actions || []
|
||||||
|
), [userPreferences, draft, chatHistory]);
|
||||||
const [showContextPanel, setShowContextPanel] = useState(false);
|
const [showContextPanel, setShowContextPanel] = useState(false);
|
||||||
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
||||||
const [showContextModal, setShowContextModal] = useState(false);
|
const [showContextModal, setShowContextModal] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [justGeneratedContent, setJustGeneratedContent] = useState(false);
|
const [justGeneratedContent, setJustGeneratedContent] = useState(false);
|
||||||
|
|
||||||
// Update suggestions when context changes
|
|
||||||
const updateSuggestions = useCallback(() => {
|
|
||||||
const newSuggestions = getContextAwareSuggestions(
|
|
||||||
userPreferences,
|
|
||||||
draft,
|
|
||||||
chatHistory.slice(-5),
|
|
||||||
userPreferences.last_used_actions || []
|
|
||||||
);
|
|
||||||
setCurrentSuggestions(newSuggestions);
|
|
||||||
}, [userPreferences, draft, chatHistory]);
|
|
||||||
|
|
||||||
// Track action usage and update preferences
|
// Track action usage and update preferences
|
||||||
const trackActionUsage = useCallback((actionName: string) => {
|
const trackActionUsage = useCallback((actionName: string) => {
|
||||||
const currentPrefs = getPreferences();
|
const currentPrefs = getPreferences();
|
||||||
@@ -82,10 +76,278 @@ export function useLinkedInWriter() {
|
|||||||
// Reset the flag after 30 seconds
|
// Reset the flag after 30 seconds
|
||||||
setTimeout(() => setJustGeneratedContent(false), 30000);
|
setTimeout(() => setJustGeneratedContent(false), 30000);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Update suggestions after action usage
|
// ── Direct generation methods (UI-driven, no CopilotKit dependency) ──────────
|
||||||
setTimeout(() => updateSuggestions(), 100);
|
const generatePost = useCallback(async (params?: any) => {
|
||||||
}, [updateSuggestions]);
|
const prefs = readPrefs();
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
|
detail: { action: 'generateLinkedInPost', message: 'Generating LinkedIn post...' }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||||
|
steps: [
|
||||||
|
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||||
|
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||||
|
{ id: 'research', label: 'Conducting research & analysis' },
|
||||||
|
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||||
|
{ id: 'content_generation', label: 'Generating content' },
|
||||||
|
{ id: 'citations', label: 'Extracting citations' },
|
||||||
|
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||||
|
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||||
|
]
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||||
|
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const res = await linkedInWriterApi.generatePost({
|
||||||
|
topic: params?.topic || prefs.topic || 'AI transformation in business',
|
||||||
|
industry: mapIndustry(params?.industry || prefs.industry),
|
||||||
|
post_type: mapPostType(params?.post_type || prefs.post_type),
|
||||||
|
tone: mapTone(params?.tone || prefs.tone),
|
||||||
|
target_audience: params?.target_audience || prefs.target_audience || 'Business leaders and professionals',
|
||||||
|
key_points: params?.key_points || prefs.key_points || [],
|
||||||
|
include_hashtags: params?.include_hashtags ?? (prefs.include_hashtags ?? true),
|
||||||
|
include_call_to_action: params?.include_call_to_action ?? (prefs.include_call_to_action ?? true),
|
||||||
|
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||||
|
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||||
|
max_length: params?.max_length || prefs.max_length || 2000,
|
||||||
|
grounding_level: 'enhanced' as GroundingLevel,
|
||||||
|
include_citations: true
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Content generated' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||||
|
const content = res.data.content;
|
||||||
|
const hashtags = res.data.hashtags?.map((h: any) => h.hashtag).join(' ') || '';
|
||||||
|
const cta = res.data.call_to_action || '';
|
||||||
|
let fullContent = content;
|
||||||
|
if (hashtags) fullContent += `\n\n${hashtags}`;
|
||||||
|
if (cta) fullContent += `\n\n${cta}`;
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateGroundingData', { detail: {
|
||||||
|
researchSources: res.research_sources || [],
|
||||||
|
citations: res.data?.citations || [],
|
||||||
|
qualityMetrics: res.data?.quality_metrics || null,
|
||||||
|
groundingEnabled: res.data?.grounding_enabled || false,
|
||||||
|
searchQueries: res.data?.search_queries || []
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: fullContent }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Content finalized' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
trackActionUsage('generateLinkedInPost');
|
||||||
|
return { success: true, data: res.data };
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||||
|
return { success: false, error: res.error || 'Generation failed' };
|
||||||
|
} catch (error: any) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||||
|
return { success: false, error: error.message || 'Generation failed' };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateArticle = useCallback(async (params?: any) => {
|
||||||
|
const prefs = readPrefs();
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
|
detail: { action: 'generateLinkedInArticle', message: 'Generating LinkedIn article...' }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||||
|
steps: [
|
||||||
|
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||||
|
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||||
|
{ id: 'research', label: 'Conducting research & analysis' },
|
||||||
|
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||||
|
{ id: 'content_generation', label: 'Generating article content' },
|
||||||
|
{ id: 'citations', label: 'Extracting citations' },
|
||||||
|
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||||
|
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||||
|
]
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||||
|
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const res = await linkedInWriterApi.generateArticle({
|
||||||
|
topic: params?.topic || prefs.topic || 'Digital transformation strategies',
|
||||||
|
industry: mapIndustry(params?.industry || prefs.industry),
|
||||||
|
tone: mapTone(params?.tone || prefs.tone),
|
||||||
|
target_audience: params?.target_audience || prefs.target_audience || 'Industry professionals and executives',
|
||||||
|
key_sections: params?.key_sections || prefs.key_sections || [],
|
||||||
|
include_images: params?.include_images ?? (prefs.include_images ?? true),
|
||||||
|
seo_optimization: params?.seo_optimization ?? (prefs.seo_optimization ?? true),
|
||||||
|
research_enabled: params?.research_enabled ?? (prefs.research_enabled ?? true),
|
||||||
|
search_engine: mapSearchEngine(params?.search_engine || prefs.search_engine),
|
||||||
|
word_count: params?.word_count || prefs.word_count || 1500,
|
||||||
|
grounding_level: 'enhanced' as GroundingLevel,
|
||||||
|
include_citations: true
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: `Prepared ${(res.data?.search_queries || []).length} research queries` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: `Research completed with ${(res.research_sources || []).length} sources` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: 'Article content generated' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: `Extracted ${(res.data?.citations || []).length} citations` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||||
|
const content = `# ${res.data.title}\n\n${res.data.content}`;
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateGroundingData', { detail: {
|
||||||
|
researchSources: res.research_sources || [],
|
||||||
|
citations: res.data?.citations || [],
|
||||||
|
qualityMetrics: res.data?.quality_metrics || null,
|
||||||
|
groundingEnabled: res.data?.grounding_enabled || false,
|
||||||
|
searchQueries: res.data?.search_queries || []
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Article finalized' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
trackActionUsage('generateLinkedInArticle');
|
||||||
|
return { success: true, data: res.data };
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||||
|
return { success: false, error: res.error || 'Generation failed' };
|
||||||
|
} catch (error: any) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||||
|
return { success: false, error: error.message || 'Generation failed' };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateCarousel = useCallback(async (params?: any) => {
|
||||||
|
const prefs = readPrefs();
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
|
detail: { action: 'generateLinkedInCarousel', message: 'Generating LinkedIn carousel...' }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||||
|
steps: [
|
||||||
|
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||||
|
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||||
|
{ id: 'research', label: 'Conducting research & analysis' },
|
||||||
|
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||||
|
{ id: 'content_generation', label: 'Generating carousel slides' },
|
||||||
|
{ id: 'citations', label: 'Extracting citations' },
|
||||||
|
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||||
|
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||||
|
]
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||||
|
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const res = await linkedInWriterApi.generateCarousel({
|
||||||
|
topic: params?.topic || prefs.topic || 'Professional development tips',
|
||||||
|
industry: mapIndustry(params?.industry || prefs.industry),
|
||||||
|
number_of_slides: params?.number_of_slides || prefs.number_of_slides || 8,
|
||||||
|
tone: mapTone(params?.tone || prefs.tone),
|
||||||
|
target_audience: params?.target_audience || prefs.target_audience || 'Professionals seeking growth',
|
||||||
|
key_takeaways: params?.key_takeaways || prefs.key_takeaways || [],
|
||||||
|
include_cover_slide: params?.include_cover_slide ?? (prefs.include_cover_slide ?? true),
|
||||||
|
include_cta_slide: params?.include_cta_slide ?? (prefs.include_cta_slide ?? true),
|
||||||
|
visual_style: params?.visual_style || prefs.visual_style || 'modern'
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated ${res.data.slides?.length || 0} slides` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||||
|
let content = `# ${res.data.title}\n\n`;
|
||||||
|
res.data.slides.forEach((slide: any, index: number) => {
|
||||||
|
content += `## Slide ${index + 1}: ${slide.title}\n\n${slide.content}\n\n`;
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Carousel finalized' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
trackActionUsage('generateLinkedInCarousel');
|
||||||
|
return { success: true, data: res.data };
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||||
|
return { success: false, error: res.error || 'Generation failed' };
|
||||||
|
} catch (error: any) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||||
|
return { success: false, error: error.message || 'Generation failed' };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateVideoScript = useCallback(async (params?: any) => {
|
||||||
|
const prefs = readPrefs();
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingStart', {
|
||||||
|
detail: { action: 'generateLinkedInVideoScript', message: 'Generating LinkedIn video script...' }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressInit', { detail: {
|
||||||
|
steps: [
|
||||||
|
{ id: 'personalize', label: 'Personalizing topic & context' },
|
||||||
|
{ id: 'prepare_queries', label: 'Preparing research queries' },
|
||||||
|
{ id: 'research', label: 'Conducting research & analysis' },
|
||||||
|
{ id: 'grounding', label: 'Applying AI grounding' },
|
||||||
|
{ id: 'content_generation', label: 'Generating video script' },
|
||||||
|
{ id: 'citations', label: 'Extracting citations' },
|
||||||
|
{ id: 'quality_analysis', label: 'Quality assessment' },
|
||||||
|
{ id: 'finalize', label: 'Finalizing & optimizing' }
|
||||||
|
]
|
||||||
|
}}));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', {
|
||||||
|
detail: { id: 'personalize', status: 'active', message: 'Analyzing topic, industry context, and target audience...' }
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const res = await linkedInWriterApi.generateVideoScript({
|
||||||
|
topic: params?.topic || prefs.topic || 'Professional networking tips',
|
||||||
|
industry: mapIndustry(params?.industry || prefs.industry),
|
||||||
|
video_length: params?.video_length || prefs.video_length || 60,
|
||||||
|
tone: mapTone(params?.tone || prefs.tone),
|
||||||
|
target_audience: params?.target_audience || prefs.target_audience || 'Professional networkers',
|
||||||
|
key_messages: params?.key_messages || prefs.key_messages || [],
|
||||||
|
include_hook: params?.include_hook ?? (prefs.include_hook ?? true),
|
||||||
|
include_captions: params?.include_captions ?? (prefs.include_captions ?? true)
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'personalize', status: 'completed', message: 'Topic personalized successfully' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'prepare_queries', status: 'completed', message: 'Prepared research queries' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'research', status: 'completed', message: 'Research completed' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'grounding', status: 'completed', message: 'AI grounding applied' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'content_generation', status: 'completed', message: `Generated script with ${res.data.main_content?.length || 0} scenes` } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'citations', status: 'completed', message: 'Citations extracted' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'quality_analysis', status: 'completed', message: 'Quality assessment completed' } }));
|
||||||
|
let content = `# Video Script: ${params?.topic || 'Professional Content'}\n\n`;
|
||||||
|
content += `## Hook\n${res.data.hook}\n\n`;
|
||||||
|
content += `## Main Content\n`;
|
||||||
|
res.data.main_content.forEach((scene: any, index: number) => {
|
||||||
|
content += `### Scene ${index + 1} (${scene.duration || '30s'})\n${scene.content}\n\n`;
|
||||||
|
});
|
||||||
|
content += `## Conclusion\n${res.data.conclusion}\n\n`;
|
||||||
|
content += `## Video Description\n${res.data.video_description}\n\n`;
|
||||||
|
if (res.data.captions) {
|
||||||
|
content += `## Captions\n${res.data.captions.join('\n')}\n\n`;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:updateDraft', { detail: content }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressStep', { detail: { id: 'finalize', status: 'completed', message: 'Video script finalized' } }));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressComplete'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
trackActionUsage('generateLinkedInVideoScript');
|
||||||
|
return { success: true, data: res.data };
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: res.error } }));
|
||||||
|
return { success: false, error: res.error || 'Generation failed' };
|
||||||
|
} catch (error: any) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:loadingEnd'));
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:progressError', { detail: { id: 'finalize', details: error.message } }));
|
||||||
|
return { success: false, error: error.message || 'Generation failed' };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize chat history and preferences from localStorage
|
// Initialize chat history and preferences from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -229,11 +491,6 @@ export function useLinkedInWriter() {
|
|||||||
}
|
}
|
||||||
}, [context]);
|
}, [context]);
|
||||||
|
|
||||||
// Update suggestions when relevant state changes
|
|
||||||
useEffect(() => {
|
|
||||||
updateSuggestions();
|
|
||||||
}, [updateSuggestions]);
|
|
||||||
|
|
||||||
// Handle draft updates from CopilotKit actions
|
// Handle draft updates from CopilotKit actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUpdateDraft = (event: CustomEvent) => {
|
const handleUpdateDraft = (event: CustomEvent) => {
|
||||||
@@ -246,9 +503,7 @@ export function useLinkedInWriter() {
|
|||||||
setCurrentAction(null);
|
setCurrentAction(null);
|
||||||
// Auto-show preview when new content is generated
|
// Auto-show preview when new content is generated
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
// Hide progress tracker when content is generated
|
// Progress is finalized by the progressStep/progressComplete events dispatched after this
|
||||||
setProgressActive(false);
|
|
||||||
setProgressSteps([]);
|
|
||||||
console.log('[LinkedIn Writer] Draft update complete');
|
console.log('[LinkedIn Writer] Draft update complete');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,22 +595,6 @@ export function useLinkedInWriter() {
|
|||||||
console.log('[LinkedIn Writer] Chat memory cleared by user');
|
console.log('[LinkedIn Writer] Chat memory cleared by user');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Make content available to CopilotKit
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'Current LinkedIn content draft',
|
|
||||||
value: draft
|
|
||||||
});
|
|
||||||
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'Context and notes for LinkedIn content',
|
|
||||||
value: context
|
|
||||||
});
|
|
||||||
|
|
||||||
useCopilotReadable({
|
|
||||||
description: 'User preferences for LinkedIn content (tone, industry, audience, style, options)',
|
|
||||||
value: userPreferences
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
draft,
|
draft,
|
||||||
@@ -403,11 +642,16 @@ export function useLinkedInWriter() {
|
|||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
trackActionUsage,
|
trackActionUsage,
|
||||||
updateSuggestions,
|
|
||||||
getHistoryLength,
|
getHistoryLength,
|
||||||
savePreferences,
|
savePreferences,
|
||||||
summarizeHistory,
|
summarizeHistory,
|
||||||
|
|
||||||
|
// Direct generation methods
|
||||||
|
generatePost,
|
||||||
|
generateArticle,
|
||||||
|
generateCarousel,
|
||||||
|
generateVideoScript,
|
||||||
|
|
||||||
// Grounding data
|
// Grounding data
|
||||||
researchSources,
|
researchSources,
|
||||||
citations,
|
citations,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export const VALID_TONES = [
|
|||||||
|
|
||||||
export const VALID_SEARCH_ENGINES = [
|
export const VALID_SEARCH_ENGINES = [
|
||||||
'google',
|
'google',
|
||||||
'tavily'
|
'tavily',
|
||||||
|
'exa'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const VALID_INDUSTRIES = [
|
export const VALID_INDUSTRIES = [
|
||||||
@@ -157,21 +158,17 @@ export function mapIndustry(industry: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapSearchEngine(engine: string | undefined): SearchEngine {
|
export function mapSearchEngine(engine: string | undefined): SearchEngine {
|
||||||
// Force Google for now until METAPHOR issue is resolved
|
|
||||||
return SearchEngine.GOOGLE;
|
|
||||||
|
|
||||||
/* Original logic - commented out temporarily
|
|
||||||
const eng = normalizeEnum(engine);
|
const eng = normalizeEnum(engine);
|
||||||
if (!eng) return SearchEngine.GOOGLE;
|
if (!eng) return SearchEngine.EXA;
|
||||||
|
|
||||||
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
const exact = VALID_SEARCH_ENGINES.find(v => v.toLowerCase() === eng);
|
||||||
if (exact) return exact as SearchEngine;
|
if (exact) return exact as SearchEngine;
|
||||||
|
|
||||||
|
if (eng.includes('exa')) return SearchEngine.EXA;
|
||||||
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
if (eng.includes('google')) return SearchEngine.GOOGLE;
|
||||||
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
if (eng.includes('tavily')) return SearchEngine.TAVILY;
|
||||||
|
|
||||||
return SearchEngine.GOOGLE;
|
return SearchEngine.EXA;
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapResponseType(responseType: string | undefined): string {
|
export function mapResponseType(responseType: string | undefined): string {
|
||||||
|
|||||||
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
23
frontend/src/hooks/useCopilotActionTyped.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCopilotAction } from '@copilotkit/react-core';
|
||||||
|
|
||||||
|
interface ParameterDescriptor {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
|
attributes?: ParameterDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CopilotActionConfig<TArgs = Record<string, any>> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters?: ParameterDescriptor[];
|
||||||
|
handler: (args: TArgs) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCopilotActionTyped<TArgs = Record<string, any>>(
|
||||||
|
config: CopilotActionConfig<TArgs>
|
||||||
|
): void {
|
||||||
|
(useCopilotAction as (config: unknown) => void)(config);
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ export enum LinkedInTone {
|
|||||||
|
|
||||||
export enum SearchEngine {
|
export enum SearchEngine {
|
||||||
GOOGLE = 'google',
|
GOOGLE = 'google',
|
||||||
TAVILY = 'tavily'
|
TAVILY = 'tavily',
|
||||||
|
EXA = 'exa'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GroundingLevel {
|
export enum GroundingLevel {
|
||||||
@@ -66,7 +67,7 @@ export interface LinkedInArticleRequest {
|
|||||||
export interface LinkedInCarouselRequest {
|
export interface LinkedInCarouselRequest {
|
||||||
topic: string;
|
topic: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
slide_count?: number;
|
number_of_slides?: number;
|
||||||
tone?: LinkedInTone;
|
tone?: LinkedInTone;
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
key_takeaways?: string[];
|
key_takeaways?: string[];
|
||||||
@@ -238,6 +239,24 @@ export interface LinkedInCommentResponseResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkedInEditContentRequest {
|
||||||
|
content: string;
|
||||||
|
edit_type: 'professionalize' | 'optimize_engagement' | 'add_hashtags' | 'adjust_tone' | 'expand' | 'condense' | 'add_cta';
|
||||||
|
industry?: string;
|
||||||
|
tone?: string;
|
||||||
|
target_audience?: string;
|
||||||
|
parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkedInEditContentResponse {
|
||||||
|
success: boolean;
|
||||||
|
content?: string;
|
||||||
|
edit_type: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// API client
|
// API client
|
||||||
export const linkedInWriterApi = {
|
export const linkedInWriterApi = {
|
||||||
async health(): Promise<any> {
|
async health(): Promise<any> {
|
||||||
@@ -270,18 +289,64 @@ export const linkedInWriterApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async optimizeProfile(request: any): Promise<any> {
|
async editContent(request: LinkedInEditContentRequest): Promise<LinkedInEditContentResponse> {
|
||||||
const { data } = await apiClient.post('/api/linkedin/optimize-profile', request);
|
const { data } = await aiApiClient.post('/api/linkedin/edit-content', request);
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async generatePoll(request: any): Promise<any> {
|
|
||||||
const { data } = await apiClient.post('/api/linkedin/generate-poll', request);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async generateCompanyUpdate(request: any): Promise<any> {
|
|
||||||
const { data } = await apiClient.post('/api/linkedin/generate-company-update', request);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Asset Library Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveLinkedInAssetParams {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
topic?: string;
|
||||||
|
tags?: string[];
|
||||||
|
assetMetadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveLinkedInAssetResult {
|
||||||
|
assetId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a LinkedIn post to the Asset Library.
|
||||||
|
* Uses the generic Content Asset API (POST /api/content-assets/).
|
||||||
|
*/
|
||||||
|
export const saveLinkedInToAssetLibrary = async (
|
||||||
|
params: SaveLinkedInAssetParams
|
||||||
|
): Promise<SaveLinkedInAssetResult> => {
|
||||||
|
// Build a filename from the title
|
||||||
|
const safeTitle = (params.title || 'linkedin-post')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 80);
|
||||||
|
const filename = `${safeTitle}-${Date.now()}.txt`;
|
||||||
|
|
||||||
|
const tags = [
|
||||||
|
'linkedin',
|
||||||
|
'social',
|
||||||
|
'ai_generated',
|
||||||
|
...(params.tags || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await aiApiClient.post('/api/content-assets/', {
|
||||||
|
asset_type: 'text',
|
||||||
|
source_module: 'linkedin_writer',
|
||||||
|
filename,
|
||||||
|
file_url: `linkedin://posts/${filename}`,
|
||||||
|
title: params.title,
|
||||||
|
description: params.content,
|
||||||
|
prompt: params.topic || '',
|
||||||
|
tags,
|
||||||
|
asset_metadata: {
|
||||||
|
platform: 'linkedin',
|
||||||
|
content_type: 'linkedin_post',
|
||||||
|
word_count: params.content ? params.content.split(/\s+/).length : 0,
|
||||||
|
...(params.assetMetadata || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { assetId: response.data.id };
|
||||||
|
};
|
||||||
|
|||||||
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
47
frontend/src/types/linkedinWriterEvents.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface LinkedInWriterGlobals {
|
||||||
|
tempPromptForGeneration?: string;
|
||||||
|
lastBrainstormEvent?: CustomEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface WindowEventMap {
|
||||||
|
'linkedinwriter:copilotSeedFromPrompt': CustomEvent<{ prompt: string }>;
|
||||||
|
'linkedinwriter:runGoogleSearchForIdeas': CustomEvent<{
|
||||||
|
prompt?: string;
|
||||||
|
seed?: string;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
usePersona?: boolean;
|
||||||
|
useGoogleSearch?: boolean;
|
||||||
|
includeTrending?: boolean;
|
||||||
|
remarketContent?: boolean;
|
||||||
|
}>;
|
||||||
|
'linkedinwriter:updateDraft': CustomEvent<string>;
|
||||||
|
'linkedinwriter:applyEdit': CustomEvent<{ target: string }>;
|
||||||
|
'linkedinwriter:loadingStart': CustomEvent<{ action: string; message: string }>;
|
||||||
|
'linkedinwriter:loadingEnd': CustomEvent<{ error?: string }>;
|
||||||
|
'linkedinwriter:progressInit': CustomEvent<{ steps: Array<{ id: string; label: string }> }>;
|
||||||
|
'linkedinwriter:progressStep': CustomEvent<{
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'completed' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
'linkedinwriter:progressComplete': CustomEvent;
|
||||||
|
'linkedinwriter:progressError': CustomEvent<{ id: string; details: string }>;
|
||||||
|
'linkedinwriter:updateGroundingData': CustomEvent<{
|
||||||
|
researchSources: any[];
|
||||||
|
citations: any[];
|
||||||
|
qualityMetrics: any;
|
||||||
|
groundingEnabled: boolean;
|
||||||
|
searchQueries: string[];
|
||||||
|
}>;
|
||||||
|
'linkedinwriter:showTodaysTasks': CustomEvent;
|
||||||
|
'linkedinwriter:updateLinkedInPreferences': CustomEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
tempPromptForGeneration?: string;
|
||||||
|
lastBrainstormEvent?: CustomEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
Reference in New Issue
Block a user