AI Image Studio, AI podcast Maker, AI product Marketing
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""API endpoints for Image Studio operations."""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.image_studio import (
|
||||
@@ -11,10 +13,12 @@ from services.image_studio import (
|
||||
EditStudioRequest,
|
||||
ControlStudioRequest,
|
||||
SocialOptimizerRequest,
|
||||
TransformImageToVideoRequest,
|
||||
TalkingAvatarRequest,
|
||||
)
|
||||
from services.image_studio.upscale_service import UpscaleStudioRequest
|
||||
from services.image_studio.templates import Platform, TemplateCategory
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
@@ -136,7 +140,12 @@ def get_studio_manager() -> ImageStudioManager:
|
||||
|
||||
def _require_user_id(current_user: Dict[str, Any], operation: str) -> str:
|
||||
"""Ensure user_id is available for protected operations."""
|
||||
user_id = current_user.get("sub") or current_user.get("user_id")
|
||||
user_id = (
|
||||
current_user.get("sub")
|
||||
or current_user.get("user_id")
|
||||
or current_user.get("id")
|
||||
or current_user.get("clerk_user_id")
|
||||
)
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"[Image Studio] ❌ Missing user_id for %s operation - blocking request",
|
||||
@@ -762,6 +771,244 @@ async def get_platform_specs(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# TRANSFORM STUDIO ENDPOINTS
|
||||
# ====================
|
||||
|
||||
class TransformImageToVideoRequestModel(BaseModel):
|
||||
"""Request model for image-to-video transformation."""
|
||||
image_base64: str = Field(..., description="Image in base64 or data URL format")
|
||||
prompt: str = Field(..., description="Text prompt describing the video")
|
||||
audio_base64: Optional[str] = Field(None, description="Optional audio file (wav/mp3, 3-30s, ≤15MB)")
|
||||
resolution: Literal["480p", "720p", "1080p"] = Field("720p", description="Output resolution")
|
||||
duration: Literal[5, 10] = Field(5, description="Video duration in seconds")
|
||||
negative_prompt: Optional[str] = Field(None, description="Negative prompt")
|
||||
seed: Optional[int] = Field(None, description="Random seed for reproducibility")
|
||||
enable_prompt_expansion: bool = Field(True, description="Enable prompt optimizer")
|
||||
|
||||
|
||||
class TalkingAvatarRequestModel(BaseModel):
|
||||
"""Request model for talking avatar generation."""
|
||||
image_base64: str = Field(..., description="Person image in base64 or data URL")
|
||||
audio_base64: str = Field(..., description="Audio file in base64 or data URL (wav/mp3, max 10 minutes)")
|
||||
resolution: Literal["480p", "720p"] = Field("720p", description="Output resolution")
|
||||
prompt: Optional[str] = Field(None, description="Optional prompt for expression/style")
|
||||
mask_image_base64: Optional[str] = Field(None, description="Optional mask for animatable regions")
|
||||
seed: Optional[int] = Field(None, description="Random seed")
|
||||
|
||||
|
||||
class TransformVideoResponse(BaseModel):
|
||||
"""Response model for video generation."""
|
||||
success: bool
|
||||
video_url: Optional[str] = None
|
||||
video_base64: Optional[str] = None
|
||||
duration: float
|
||||
resolution: str
|
||||
width: int
|
||||
height: int
|
||||
file_size: int
|
||||
cost: float
|
||||
provider: str
|
||||
model: str
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class TransformCostEstimateRequest(BaseModel):
|
||||
"""Request model for cost estimation."""
|
||||
operation: Literal["image-to-video", "talking-avatar"] = Field(..., description="Operation type")
|
||||
resolution: str = Field(..., description="Output resolution")
|
||||
duration: Optional[int] = Field(None, description="Video duration in seconds (for image-to-video)")
|
||||
|
||||
|
||||
class TransformCostEstimateResponse(BaseModel):
|
||||
"""Response model for cost estimation."""
|
||||
estimated_cost: float
|
||||
breakdown: Dict[str, Any]
|
||||
currency: str
|
||||
provider: str
|
||||
model: str
|
||||
|
||||
|
||||
@router.post("/transform/image-to-video", response_model=TransformVideoResponse, summary="Transform Image to Video")
|
||||
async def transform_image_to_video(
|
||||
request: TransformImageToVideoRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Transform an image into a video using WAN 2.5.
|
||||
|
||||
This endpoint generates a video from an image and text prompt, with optional audio synchronization.
|
||||
Supports resolutions of 480p, 720p, and 1080p, with durations of 5 or 10 seconds.
|
||||
|
||||
Returns:
|
||||
Video generation result with URL and metadata
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "image-to-video transformation")
|
||||
logger.info(f"[Transform Studio] Image-to-video request from user {user_id}: resolution={request.resolution}, duration={request.duration}s")
|
||||
|
||||
# Convert request to service request
|
||||
transform_request = TransformImageToVideoRequest(
|
||||
image_base64=request.image_base64,
|
||||
prompt=request.prompt,
|
||||
audio_base64=request.audio_base64,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
negative_prompt=request.negative_prompt,
|
||||
seed=request.seed,
|
||||
enable_prompt_expansion=request.enable_prompt_expansion,
|
||||
)
|
||||
|
||||
# Generate video
|
||||
result = await studio_manager.transform_image_to_video(transform_request, user_id=user_id)
|
||||
|
||||
logger.info(f"[Transform Studio] ✅ Image-to-video completed: cost=${result['cost']:.2f}")
|
||||
return TransformVideoResponse(**result)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"[Transform Studio] ❌ Validation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Transform Studio] ❌ Unexpected error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Video generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/transform/talking-avatar", response_model=TransformVideoResponse, summary="Create Talking Avatar")
|
||||
async def create_talking_avatar(
|
||||
request: TalkingAvatarRequestModel,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Create a talking avatar video using InfiniteTalk.
|
||||
|
||||
This endpoint generates a video with precise lip-sync from an image and audio file.
|
||||
Supports resolutions of 480p and 720p, with videos up to 10 minutes long.
|
||||
|
||||
Returns:
|
||||
Video generation result with URL and metadata
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "talking avatar generation")
|
||||
logger.info(f"[Transform Studio] Talking avatar request from user {user_id}: resolution={request.resolution}")
|
||||
|
||||
# Convert request to service request
|
||||
avatar_request = TalkingAvatarRequest(
|
||||
image_base64=request.image_base64,
|
||||
audio_base64=request.audio_base64,
|
||||
resolution=request.resolution,
|
||||
prompt=request.prompt,
|
||||
mask_image_base64=request.mask_image_base64,
|
||||
seed=request.seed,
|
||||
)
|
||||
|
||||
# Generate video
|
||||
result = await studio_manager.create_talking_avatar(avatar_request, user_id=user_id)
|
||||
|
||||
logger.info(f"[Transform Studio] ✅ Talking avatar completed: cost=${result['cost']:.2f}")
|
||||
return TransformVideoResponse(**result)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"[Transform Studio] ❌ Validation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Transform Studio] ❌ Unexpected error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Talking avatar generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/transform/estimate-cost", response_model=TransformCostEstimateResponse, summary="Estimate Transform Cost")
|
||||
async def estimate_transform_cost(
|
||||
request: TransformCostEstimateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
studio_manager: ImageStudioManager = Depends(get_studio_manager),
|
||||
):
|
||||
"""Estimate cost for transform operations.
|
||||
|
||||
Provides cost estimates before generation to help users make informed decisions.
|
||||
|
||||
Returns:
|
||||
Cost estimation details
|
||||
"""
|
||||
try:
|
||||
estimate = studio_manager.estimate_transform_cost(
|
||||
operation=request.operation,
|
||||
resolution=request.resolution,
|
||||
duration=request.duration,
|
||||
)
|
||||
return TransformCostEstimateResponse(**estimate)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"[Transform Studio] ❌ Cost estimation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"[Transform Studio] ❌ Error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/videos/{user_id}/{video_filename:path}", summary="Serve Transform Studio Video")
|
||||
async def serve_transform_video(
|
||||
user_id: str,
|
||||
video_filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve a generated Transform Studio video file.
|
||||
|
||||
Args:
|
||||
user_id: User ID from URL path
|
||||
video_filename: Video filename
|
||||
current_user: Authenticated user
|
||||
|
||||
Returns:
|
||||
Video file response
|
||||
"""
|
||||
try:
|
||||
# Verify user has access (must be the owner)
|
||||
authenticated_user_id = _require_user_id(current_user, "video access")
|
||||
if authenticated_user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied: You can only access your own videos"
|
||||
)
|
||||
|
||||
# Resolve video path
|
||||
# __file__ is: backend/routers/image_studio.py
|
||||
# We need: backend/transform_videos
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
transform_videos_dir = base_dir / "transform_videos"
|
||||
video_path = transform_videos_dir / user_id / video_filename
|
||||
|
||||
# Security: Ensure path is within transform_videos directory
|
||||
# Prevent directory traversal attacks
|
||||
try:
|
||||
resolved_video_path = video_path.resolve()
|
||||
resolved_base = transform_videos_dir.resolve()
|
||||
# Check if video path is within base directory
|
||||
resolved_video_path.relative_to(resolved_base)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid video path: path traversal detected"
|
||||
)
|
||||
|
||||
if not video_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
return FileResponse(
|
||||
path=str(video_path),
|
||||
media_type="video/mp4",
|
||||
filename=video_filename
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Transform Studio] Failed to serve video: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# HEALTH CHECK
|
||||
# ====================
|
||||
|
||||
@@ -8,9 +8,10 @@ proper error handling, monitoring, and documentation.
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
import time
|
||||
from loguru import logger
|
||||
from pathlib import Path
|
||||
|
||||
from models.linkedin_models import (
|
||||
LinkedInPostRequest, LinkedInArticleRequest, LinkedInCarouselRequest,
|
||||
@@ -19,11 +20,13 @@ from models.linkedin_models import (
|
||||
LinkedInVideoScriptResponse, LinkedInCommentResponseResult
|
||||
)
|
||||
from services.linkedin_service import LinkedInService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from utils.text_asset_tracker import save_and_track_text_content
|
||||
|
||||
# Initialize the LinkedIn service instance
|
||||
linkedin_service = LinkedInService()
|
||||
from services.subscription.monitoring_middleware import DatabaseAPIMonitor
|
||||
from services.database import get_db_session
|
||||
from services.database import get_db as get_db_dependency
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Initialize router
|
||||
@@ -41,14 +44,8 @@ router = APIRouter(
|
||||
monitor = DatabaseAPIMonitor()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency to get database session."""
|
||||
db = get_db_session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
# Use the proper database dependency from services.database
|
||||
get_db = get_db_dependency
|
||||
|
||||
|
||||
async def log_api_request(request: Request, db: Session, duration: float, status_code: int):
|
||||
@@ -104,7 +101,8 @@ async def generate_post(
|
||||
request: LinkedInPostRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate a LinkedIn post based on the provided parameters."""
|
||||
start_time = time.time()
|
||||
@@ -119,6 +117,13 @@ async def generate_post(
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
|
||||
# Extract user_id
|
||||
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")
|
||||
|
||||
# Generate post content
|
||||
response = await linkedin_service.generate_linkedin_post(request)
|
||||
|
||||
@@ -131,6 +136,38 @@ async def generate_post(
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data and response.data.content:
|
||||
try:
|
||||
# Combine all text content
|
||||
text_content = response.data.content
|
||||
if response.data.call_to_action:
|
||||
text_content += f"\n\nCall to Action: {response.data.call_to_action}"
|
||||
if response.data.hashtags:
|
||||
hashtag_text = " ".join([f"#{h.hashtag}" if isinstance(h, dict) else f"#{h.get('hashtag', '')}" for h in response.data.hashtags])
|
||||
text_content += f"\n\nHashtags: {hashtag_text}"
|
||||
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=text_content,
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Post: {request.topic[:80]}",
|
||||
description=f"LinkedIn post for {request.industry} industry",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nTone: {request.tone}",
|
||||
tags=["linkedin", "post", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"post_type": request.post_type.value if hasattr(request.post_type, 'value') else str(request.post_type),
|
||||
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
|
||||
"character_count": response.data.character_count,
|
||||
"hashtag_count": len(response.data.hashtags),
|
||||
"grounding_enabled": response.data.grounding_enabled if hasattr(response.data, 'grounding_enabled') else False
|
||||
},
|
||||
subdirectory="posts"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn post asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn post in {duration:.2f} seconds")
|
||||
return response
|
||||
|
||||
@@ -174,7 +211,8 @@ async def generate_article(
|
||||
request: LinkedInArticleRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate a LinkedIn article based on the provided parameters."""
|
||||
start_time = time.time()
|
||||
@@ -189,6 +227,13 @@ async def generate_article(
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
|
||||
# Extract user_id
|
||||
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")
|
||||
|
||||
# Generate article content
|
||||
response = await linkedin_service.generate_linkedin_article(request)
|
||||
|
||||
@@ -201,6 +246,44 @@ async def generate_article(
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
try:
|
||||
# Combine article content
|
||||
text_content = f"# {response.data.title}\n\n"
|
||||
text_content += response.data.content
|
||||
|
||||
if response.data.sections:
|
||||
text_content += "\n\n## Sections:\n"
|
||||
for section in response.data.sections:
|
||||
if isinstance(section, dict):
|
||||
text_content += f"\n### {section.get('heading', 'Section')}\n{section.get('content', '')}\n"
|
||||
|
||||
if response.data.seo_metadata:
|
||||
text_content += f"\n\n## SEO Metadata\n{response.data.seo_metadata}\n"
|
||||
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=text_content,
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Article: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
||||
description=f"LinkedIn article for {request.industry} industry",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nTone: {request.tone}\nWord Count: {request.word_count}",
|
||||
tags=["linkedin", "article", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
|
||||
"word_count": response.data.word_count,
|
||||
"reading_time": response.data.reading_time,
|
||||
"section_count": len(response.data.sections) if response.data.sections else 0,
|
||||
"grounding_enabled": response.data.grounding_enabled if hasattr(response.data, 'grounding_enabled') else False
|
||||
},
|
||||
subdirectory="articles",
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn article asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn article in {duration:.2f} seconds")
|
||||
return response
|
||||
|
||||
@@ -243,7 +326,8 @@ async def generate_carousel(
|
||||
request: LinkedInCarouselRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate a LinkedIn carousel based on the provided parameters."""
|
||||
start_time = time.time()
|
||||
@@ -261,6 +345,13 @@ async def generate_carousel(
|
||||
if request.slide_count < 3 or request.slide_count > 15:
|
||||
raise HTTPException(status_code=422, detail="Slide count must be between 3 and 15")
|
||||
|
||||
# Extract user_id
|
||||
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")
|
||||
|
||||
# Generate carousel content
|
||||
response = await linkedin_service.generate_linkedin_carousel(request)
|
||||
|
||||
@@ -273,6 +364,36 @@ async def generate_carousel(
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
try:
|
||||
# Combine carousel content
|
||||
text_content = f"# {response.data.title}\n\n"
|
||||
for slide in response.data.slides:
|
||||
text_content += f"\n## Slide {slide.slide_number}: {slide.title}\n{slide.content}\n"
|
||||
if slide.visual_elements:
|
||||
text_content += f"\nVisual Elements: {', '.join(slide.visual_elements)}\n"
|
||||
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=text_content,
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Carousel: {response.data.title[:80] if response.data.title else request.topic[:80]}",
|
||||
description=f"LinkedIn carousel for {request.industry} industry",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nSlides: {getattr(request, 'number_of_slides', request.slide_count if hasattr(request, 'slide_count') else 5)}",
|
||||
tags=["linkedin", "carousel", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"slide_count": len(response.data.slides),
|
||||
"has_cover": response.data.cover_slide is not None,
|
||||
"has_cta": response.data.cta_slide is not None
|
||||
},
|
||||
subdirectory="carousels",
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn carousel asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn carousel in {duration:.2f} seconds")
|
||||
return response
|
||||
|
||||
@@ -315,7 +436,8 @@ async def generate_video_script(
|
||||
request: LinkedInVideoScriptRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate a LinkedIn video script based on the provided parameters."""
|
||||
start_time = time.time()
|
||||
@@ -330,9 +452,17 @@ async def generate_video_script(
|
||||
if not request.industry.strip():
|
||||
raise HTTPException(status_code=422, detail="Industry cannot be empty")
|
||||
|
||||
if request.video_length < 15 or request.video_length > 300:
|
||||
video_duration = getattr(request, 'video_duration', getattr(request, 'video_length', 60))
|
||||
if video_duration < 15 or video_duration > 300:
|
||||
raise HTTPException(status_code=422, detail="Video length must be between 15 and 300 seconds")
|
||||
|
||||
# Extract user_id
|
||||
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")
|
||||
|
||||
# Generate video script content
|
||||
response = await linkedin_service.generate_linkedin_video_script(request)
|
||||
|
||||
@@ -345,6 +475,47 @@ async def generate_video_script(
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and response.data:
|
||||
try:
|
||||
# Combine video script content
|
||||
text_content = f"# Video Script: {request.topic}\n\n"
|
||||
text_content += f"## Hook\n{response.data.hook}\n\n"
|
||||
text_content += "## Main Content\n"
|
||||
for scene in response.data.main_content:
|
||||
if isinstance(scene, dict):
|
||||
text_content += f"\n### Scene {scene.get('scene_number', '')}\n"
|
||||
text_content += f"{scene.get('content', '')}\n"
|
||||
if scene.get('duration'):
|
||||
text_content += f"Duration: {scene.get('duration')}s\n"
|
||||
if scene.get('visual_notes'):
|
||||
text_content += f"Visual Notes: {scene.get('visual_notes')}\n"
|
||||
text_content += f"\n## Conclusion\n{response.data.conclusion}\n"
|
||||
if response.data.captions:
|
||||
text_content += f"\n## Captions\n" + "\n".join(response.data.captions) + "\n"
|
||||
if response.data.thumbnail_suggestions:
|
||||
text_content += f"\n## Thumbnail Suggestions\n" + "\n".join(response.data.thumbnail_suggestions) + "\n"
|
||||
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=text_content,
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Video Script: {request.topic[:80]}",
|
||||
description=f"LinkedIn video script for {request.industry} industry",
|
||||
prompt=f"Topic: {request.topic}\nIndustry: {request.industry}\nDuration: {video_duration}s",
|
||||
tags=["linkedin", "video_script", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"video_duration": video_duration,
|
||||
"scene_count": len(response.data.main_content),
|
||||
"has_captions": bool(response.data.captions)
|
||||
},
|
||||
subdirectory="video_scripts",
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn video script asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn video script in {duration:.2f} seconds")
|
||||
return response
|
||||
|
||||
@@ -387,7 +558,8 @@ async def generate_comment_response(
|
||||
request: LinkedInCommentResponseRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
):
|
||||
"""Generate a LinkedIn comment response based on the provided parameters."""
|
||||
start_time = time.time()
|
||||
@@ -396,11 +568,21 @@ async def generate_comment_response(
|
||||
logger.info("Received LinkedIn comment response generation request")
|
||||
|
||||
# Validate request
|
||||
if not request.original_post.strip():
|
||||
raise HTTPException(status_code=422, detail="Original post cannot be empty")
|
||||
original_comment = getattr(request, 'original_comment', getattr(request, 'comment', ''))
|
||||
post_context = getattr(request, 'post_context', getattr(request, 'original_post', ''))
|
||||
|
||||
if not request.comment.strip():
|
||||
raise HTTPException(status_code=422, detail="Comment cannot be empty")
|
||||
if not original_comment.strip():
|
||||
raise HTTPException(status_code=422, detail="Original comment cannot be empty")
|
||||
|
||||
if not post_context.strip():
|
||||
raise HTTPException(status_code=422, detail="Post context cannot be empty")
|
||||
|
||||
# Extract user_id
|
||||
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")
|
||||
|
||||
# Generate comment response
|
||||
response = await linkedin_service.generate_linkedin_comment_response(request)
|
||||
@@ -414,6 +596,38 @@ async def generate_comment_response(
|
||||
if not response.success:
|
||||
raise HTTPException(status_code=500, detail=response.error)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if user_id and hasattr(response, 'response') and response.response:
|
||||
try:
|
||||
text_content = f"# Comment Response\n\n"
|
||||
text_content += f"## Original Comment\n{original_comment}\n\n"
|
||||
text_content += f"## Post Context\n{post_context}\n\n"
|
||||
text_content += f"## Generated Response\n{response.response}\n"
|
||||
if hasattr(response, 'alternatives') and response.alternatives:
|
||||
text_content += f"\n## Alternative Responses\n"
|
||||
for i, alt in enumerate(response.alternatives, 1):
|
||||
text_content += f"\n### Alternative {i}\n{alt}\n"
|
||||
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=text_content,
|
||||
source_module="linkedin_writer",
|
||||
title=f"LinkedIn Comment Response: {original_comment[:60]}",
|
||||
description=f"LinkedIn comment response for {request.industry} industry",
|
||||
prompt=f"Original Comment: {original_comment}\nPost Context: {post_context}\nIndustry: {request.industry}",
|
||||
tags=["linkedin", "comment_response", request.industry.lower().replace(' ', '_')],
|
||||
asset_metadata={
|
||||
"response_length": getattr(request, 'response_length', 'medium'),
|
||||
"tone": request.tone.value if hasattr(request.tone, 'value') else str(request.tone),
|
||||
"has_alternatives": hasattr(response, 'alternatives') and bool(response.alternatives)
|
||||
},
|
||||
subdirectory="comment_responses",
|
||||
file_extension=".md"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track LinkedIn comment response asset: {track_error}")
|
||||
|
||||
logger.info(f"Successfully generated LinkedIn comment response in {duration:.2f} seconds")
|
||||
return response
|
||||
|
||||
|
||||
640
backend/routers/product_marketing.py
Normal file
640
backend/routers/product_marketing.py
Normal file
@@ -0,0 +1,640 @@
|
||||
"""API endpoints for Product Marketing Suite."""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.product_marketing import (
|
||||
ProductMarketingOrchestrator,
|
||||
BrandDNASyncService,
|
||||
AssetAuditService,
|
||||
ChannelPackService,
|
||||
)
|
||||
from services.product_marketing.campaign_storage import CampaignStorageService
|
||||
from services.product_marketing.product_image_service import ProductImageService, ProductImageRequest
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from utils.logger_utils import get_service_logger
|
||||
from services.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
logger = get_service_logger("api.product_marketing")
|
||||
router = APIRouter(prefix="/api/product-marketing", tags=["product-marketing"])
|
||||
|
||||
|
||||
# ====================
|
||||
# REQUEST MODELS
|
||||
# ====================
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
"""Request to create a new campaign blueprint."""
|
||||
campaign_name: str = Field(..., description="Campaign name")
|
||||
goal: str = Field(..., description="Campaign goal (product_launch, awareness, conversion, etc.)")
|
||||
kpi: Optional[str] = Field(None, description="Key performance indicator")
|
||||
channels: List[str] = Field(..., description="Target channels (instagram, linkedin, tiktok, etc.)")
|
||||
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
|
||||
|
||||
|
||||
class AssetProposalRequest(BaseModel):
|
||||
"""Request to generate asset proposals."""
|
||||
campaign_id: str = Field(..., description="Campaign ID")
|
||||
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
|
||||
|
||||
|
||||
class AssetGenerateRequest(BaseModel):
|
||||
"""Request to generate a specific asset."""
|
||||
asset_proposal: Dict[str, Any] = Field(..., description="Asset proposal from generate_proposals")
|
||||
product_context: Optional[Dict[str, Any]] = Field(None, description="Product information")
|
||||
|
||||
|
||||
class AssetAuditRequest(BaseModel):
|
||||
"""Request to audit uploaded assets."""
|
||||
image_base64: str = Field(..., description="Base64 encoded image")
|
||||
asset_metadata: Optional[Dict[str, Any]] = Field(None, description="Asset metadata")
|
||||
|
||||
|
||||
# ====================
|
||||
# DEPENDENCY
|
||||
# ====================
|
||||
|
||||
def get_orchestrator() -> ProductMarketingOrchestrator:
|
||||
"""Get Product Marketing Orchestrator instance."""
|
||||
return ProductMarketingOrchestrator()
|
||||
|
||||
|
||||
def get_campaign_storage() -> CampaignStorageService:
|
||||
"""Get Campaign Storage Service instance."""
|
||||
return CampaignStorageService()
|
||||
|
||||
|
||||
def _require_user_id(current_user: Dict[str, Any], operation: str) -> str:
|
||||
"""Ensure user_id is available for protected operations."""
|
||||
user_id = current_user.get("sub") or current_user.get("user_id") or current_user.get("id")
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"[Product Marketing] ❌ Missing user_id for %s operation - blocking request",
|
||||
operation,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authenticated user required for product marketing operations.",
|
||||
)
|
||||
return str(user_id)
|
||||
|
||||
|
||||
# ====================
|
||||
# CAMPAIGN ENDPOINTS
|
||||
# ====================
|
||||
|
||||
@router.post("/campaigns/validate-preflight", summary="Validate Campaign Pre-flight")
|
||||
async def validate_campaign_preflight(
|
||||
request: CampaignCreateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
|
||||
):
|
||||
"""Validate campaign blueprint against subscription limits before creation.
|
||||
|
||||
This endpoint:
|
||||
- Creates a temporary blueprint to estimate costs
|
||||
- Validates subscription limits
|
||||
- Returns cost estimates and validation results
|
||||
- Does NOT save anything to database
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "campaign pre-flight validation")
|
||||
logger.info(f"[Product Marketing] Pre-flight validation for user {user_id}")
|
||||
|
||||
# Create temporary blueprint for validation (not saved)
|
||||
campaign_data = {
|
||||
"campaign_name": request.campaign_name or "Temporary Campaign",
|
||||
"goal": request.goal,
|
||||
"kpi": request.kpi,
|
||||
"channels": request.channels,
|
||||
}
|
||||
|
||||
blueprint = orchestrator.create_campaign_blueprint(user_id, campaign_data)
|
||||
|
||||
# Run pre-flight validation
|
||||
validation_result = orchestrator.validate_campaign_preflight(user_id, blueprint)
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Pre-flight validation completed: can_proceed={validation_result.get('can_proceed')}")
|
||||
return validation_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error in pre-flight validation: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Pre-flight validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/campaigns/create-blueprint", summary="Create Campaign Blueprint")
|
||||
async def create_campaign_blueprint(
|
||||
request: CampaignCreateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
|
||||
):
|
||||
"""Create a campaign blueprint with personalized asset nodes.
|
||||
|
||||
This endpoint:
|
||||
- Uses onboarding data to personalize the blueprint
|
||||
- Generates campaign phases (teaser, launch, nurture)
|
||||
- Creates asset nodes for each phase and channel
|
||||
- Returns blueprint ready for AI proposal generation
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "campaign blueprint creation")
|
||||
logger.info(f"[Product Marketing] Creating blueprint for user {user_id}: {request.campaign_name}")
|
||||
|
||||
campaign_data = {
|
||||
"campaign_name": request.campaign_name,
|
||||
"goal": request.goal,
|
||||
"kpi": request.kpi,
|
||||
"channels": request.channels,
|
||||
}
|
||||
|
||||
blueprint = orchestrator.create_campaign_blueprint(user_id, campaign_data)
|
||||
|
||||
# Convert blueprint to dict for JSON response
|
||||
blueprint_dict = {
|
||||
"campaign_id": blueprint.campaign_id,
|
||||
"campaign_name": blueprint.campaign_name,
|
||||
"goal": blueprint.goal,
|
||||
"kpi": blueprint.kpi,
|
||||
"phases": blueprint.phases,
|
||||
"asset_nodes": [
|
||||
{
|
||||
"asset_id": node.asset_id,
|
||||
"asset_type": node.asset_type,
|
||||
"channel": node.channel,
|
||||
"status": node.status,
|
||||
}
|
||||
for node in blueprint.asset_nodes
|
||||
],
|
||||
"channels": blueprint.channels,
|
||||
"status": blueprint.status,
|
||||
}
|
||||
|
||||
# Save to database
|
||||
campaign_storage = get_campaign_storage()
|
||||
campaign_storage.save_campaign(user_id, blueprint_dict)
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Blueprint created and saved: {blueprint.campaign_id}")
|
||||
return blueprint_dict
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error creating blueprint: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Campaign blueprint creation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/campaigns/{campaign_id}/generate-proposals", summary="Generate Asset Proposals")
|
||||
async def generate_asset_proposals(
|
||||
campaign_id: str,
|
||||
request: AssetProposalRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
|
||||
):
|
||||
"""Generate AI proposals for all assets in a campaign blueprint.
|
||||
|
||||
This endpoint:
|
||||
- Uses specialized marketing prompts with brand DNA
|
||||
- Recommends templates, providers, and settings
|
||||
- Provides cost estimates
|
||||
- Returns proposals ready for user approval
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "asset proposal generation")
|
||||
logger.info(f"[Product Marketing] Generating proposals for campaign {campaign_id}")
|
||||
|
||||
# Fetch blueprint from database
|
||||
campaign_storage = get_campaign_storage()
|
||||
campaign = campaign_storage.get_campaign(user_id, campaign_id)
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Reconstruct blueprint from database
|
||||
from services.product_marketing.orchestrator import CampaignBlueprint, CampaignAssetNode
|
||||
|
||||
asset_nodes = []
|
||||
if campaign.asset_nodes:
|
||||
for node_data in campaign.asset_nodes:
|
||||
asset_nodes.append(CampaignAssetNode(
|
||||
asset_id=node_data.get('asset_id'),
|
||||
asset_type=node_data.get('asset_type'),
|
||||
channel=node_data.get('channel'),
|
||||
status=node_data.get('status', 'draft'),
|
||||
))
|
||||
|
||||
blueprint = CampaignBlueprint(
|
||||
campaign_id=campaign.campaign_id,
|
||||
campaign_name=campaign.campaign_name,
|
||||
goal=campaign.goal,
|
||||
kpi=campaign.kpi,
|
||||
channels=campaign.channels or [],
|
||||
asset_nodes=asset_nodes,
|
||||
)
|
||||
|
||||
proposals = orchestrator.generate_asset_proposals(
|
||||
user_id=user_id,
|
||||
blueprint=blueprint,
|
||||
product_context=request.product_context,
|
||||
)
|
||||
|
||||
# Save proposals to database
|
||||
try:
|
||||
campaign_storage.save_proposals(user_id, campaign_id, proposals)
|
||||
logger.info(f"[Product Marketing] ✅ Saved {proposals['total_assets']} proposals to database")
|
||||
except Exception as save_error:
|
||||
logger.error(f"[Product Marketing] ⚠️ Failed to save proposals to database: {str(save_error)}")
|
||||
# Continue even if save fails - proposals are still returned to user
|
||||
# This allows the workflow to continue, but proposals won't persist
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Generated {proposals['total_assets']} proposals")
|
||||
return proposals
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error generating proposals: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Asset proposal generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/assets/generate", summary="Generate Asset")
|
||||
async def generate_asset(
|
||||
request: AssetGenerateRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
orchestrator: ProductMarketingOrchestrator = Depends(get_orchestrator)
|
||||
):
|
||||
"""Generate a single asset using Image Studio APIs.
|
||||
|
||||
This endpoint:
|
||||
- Reuses existing Image Studio APIs
|
||||
- Applies specialized marketing prompts
|
||||
- Automatically tracks assets in Asset Library
|
||||
- Validates subscription limits
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "asset generation")
|
||||
logger.info(f"[Product Marketing] Generating asset for user {user_id}")
|
||||
|
||||
result = await orchestrator.generate_asset(
|
||||
user_id=user_id,
|
||||
asset_proposal=request.asset_proposal,
|
||||
product_context=request.product_context,
|
||||
)
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Asset generated successfully")
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error generating asset: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Asset generation failed: {str(e)}")
|
||||
|
||||
|
||||
# ====================
|
||||
# BRAND DNA ENDPOINTS
|
||||
# ====================
|
||||
|
||||
@router.get("/brand-dna", summary="Get Brand DNA Tokens")
|
||||
async def get_brand_dna(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Get brand DNA tokens for the authenticated user.
|
||||
|
||||
Returns normalized brand DNA from onboarding and persona data.
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "brand DNA retrieval")
|
||||
brand_tokens = brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
|
||||
return {"brand_dna": brand_tokens}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error getting brand DNA: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/brand-dna/channel/{channel}", summary="Get Channel-Specific Brand DNA")
|
||||
async def get_channel_brand_dna(
|
||||
channel: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Get channel-specific brand DNA adaptations."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "channel brand DNA retrieval")
|
||||
channel_dna = brand_dna_sync.get_channel_specific_dna(user_id, channel)
|
||||
|
||||
return {"channel": channel, "brand_dna": channel_dna}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error getting channel DNA: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# ASSET AUDIT ENDPOINTS
|
||||
# ====================
|
||||
|
||||
@router.post("/assets/audit", summary="Audit Asset")
|
||||
async def audit_asset(
|
||||
request: AssetAuditRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
asset_audit: AssetAuditService = Depends(lambda: AssetAuditService())
|
||||
):
|
||||
"""Audit an uploaded asset and get enhancement recommendations."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "asset audit")
|
||||
audit_result = asset_audit.audit_asset(
|
||||
request.image_base64,
|
||||
request.asset_metadata,
|
||||
)
|
||||
|
||||
return audit_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error auditing asset: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# CHANNEL PACK ENDPOINTS
|
||||
# ====================
|
||||
|
||||
@router.get("/channels/{channel}/pack", summary="Get Channel Pack")
|
||||
async def get_channel_pack(
|
||||
channel: str,
|
||||
asset_type: str = "social_post",
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
channel_pack: ChannelPackService = Depends(lambda: ChannelPackService())
|
||||
):
|
||||
"""Get channel-specific pack configuration with templates and optimization tips."""
|
||||
try:
|
||||
pack = channel_pack.get_channel_pack(channel, asset_type)
|
||||
return pack
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error getting channel pack: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# CAMPAIGN LISTING & RETRIEVAL
|
||||
# ====================
|
||||
|
||||
@router.get("/campaigns", summary="List Campaigns")
|
||||
async def list_campaigns(
|
||||
status: Optional[str] = None,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
|
||||
):
|
||||
"""List all campaigns for the authenticated user."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "list campaigns")
|
||||
campaigns = campaign_storage.list_campaigns(user_id, status=status)
|
||||
|
||||
return {
|
||||
"campaigns": [
|
||||
{
|
||||
"campaign_id": c.campaign_id,
|
||||
"campaign_name": c.campaign_name,
|
||||
"goal": c.goal,
|
||||
"kpi": c.kpi,
|
||||
"status": c.status,
|
||||
"channels": c.channels,
|
||||
"phases": c.phases,
|
||||
"asset_nodes": c.asset_nodes,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in campaigns
|
||||
],
|
||||
"total": len(campaigns),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error listing campaigns: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}", summary="Get Campaign")
|
||||
async def get_campaign(
|
||||
campaign_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
|
||||
):
|
||||
"""Get a specific campaign by ID."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "get campaign")
|
||||
campaign = campaign_storage.get_campaign(user_id, campaign_id)
|
||||
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
return {
|
||||
"campaign_id": campaign.campaign_id,
|
||||
"campaign_name": campaign.campaign_name,
|
||||
"goal": campaign.goal,
|
||||
"kpi": campaign.kpi,
|
||||
"status": campaign.status,
|
||||
"channels": campaign.channels,
|
||||
"phases": campaign.phases,
|
||||
"asset_nodes": campaign.asset_nodes,
|
||||
"product_context": campaign.product_context,
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error getting campaign: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}/proposals", summary="Get Campaign Proposals")
|
||||
async def get_campaign_proposals(
|
||||
campaign_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
campaign_storage: CampaignStorageService = Depends(get_campaign_storage)
|
||||
):
|
||||
"""Get proposals for a campaign."""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "get proposals")
|
||||
proposals = campaign_storage.get_proposals(user_id, campaign_id)
|
||||
|
||||
proposals_dict = {}
|
||||
for proposal in proposals:
|
||||
proposals_dict[proposal.asset_node_id] = {
|
||||
"asset_id": proposal.asset_node_id,
|
||||
"asset_type": proposal.asset_type,
|
||||
"channel": proposal.channel,
|
||||
"proposed_prompt": proposal.proposed_prompt,
|
||||
"recommended_template": proposal.recommended_template,
|
||||
"recommended_provider": proposal.recommended_provider,
|
||||
"cost_estimate": proposal.cost_estimate,
|
||||
"concept_summary": proposal.concept_summary,
|
||||
"status": proposal.status,
|
||||
}
|
||||
|
||||
return {
|
||||
"proposals": proposals_dict,
|
||||
"total_assets": len(proposals),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error getting proposals: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# PRODUCT ASSET ENDPOINTS (Product Marketing Suite - Product Assets)
|
||||
# ====================
|
||||
|
||||
class ProductPhotoshootRequest(BaseModel):
|
||||
"""Request for product image photoshoot generation."""
|
||||
product_name: str = Field(..., description="Product name")
|
||||
product_description: str = Field(..., description="Product description")
|
||||
environment: str = Field(default="studio", description="Environment: studio, lifestyle, outdoor, minimalist, luxury")
|
||||
background_style: str = Field(default="white", description="Background: white, transparent, lifestyle, branded")
|
||||
lighting: str = Field(default="natural", description="Lighting: natural, studio, dramatic, soft")
|
||||
product_variant: Optional[str] = Field(None, description="Product variant (color, size, etc.)")
|
||||
angle: Optional[str] = Field(None, description="Product angle: front, side, top, 360")
|
||||
style: str = Field(default="photorealistic", description="Style: photorealistic, minimalist, luxury, technical")
|
||||
resolution: str = Field(default="1024x1024", description="Resolution (e.g., 1024x1024, 1280x720)")
|
||||
num_variations: int = Field(default=1, description="Number of variations to generate")
|
||||
brand_colors: Optional[List[str]] = Field(None, description="Brand color palette")
|
||||
additional_context: Optional[str] = Field(None, description="Additional context for generation")
|
||||
|
||||
|
||||
def get_product_image_service() -> ProductImageService:
|
||||
"""Get Product Image Service instance."""
|
||||
return ProductImageService()
|
||||
|
||||
|
||||
@router.post("/products/photoshoot", summary="Generate Product Image")
|
||||
async def generate_product_image(
|
||||
request: ProductPhotoshootRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
product_image_service: ProductImageService = Depends(get_product_image_service),
|
||||
brand_dna_sync: BrandDNASyncService = Depends(lambda: BrandDNASyncService())
|
||||
):
|
||||
"""Generate professional product images using AI.
|
||||
|
||||
This endpoint:
|
||||
- Generates product images optimized for e-commerce
|
||||
- Supports multiple environments and styles
|
||||
- Integrates with brand DNA for personalization
|
||||
- Automatically saves to Asset Library
|
||||
"""
|
||||
try:
|
||||
user_id = _require_user_id(current_user, "product image generation")
|
||||
logger.info(f"[Product Marketing] Generating product image for '{request.product_name}'")
|
||||
|
||||
# Get brand DNA for personalization
|
||||
brand_context = None
|
||||
try:
|
||||
brand_dna = brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
brand_context = {
|
||||
"visual_identity": brand_dna.get("visual_identity", {}),
|
||||
"persona": brand_dna.get("persona", {}),
|
||||
}
|
||||
except Exception as brand_error:
|
||||
logger.warning(f"[Product Marketing] Could not load brand DNA: {str(brand_error)}")
|
||||
|
||||
# Convert request to service request
|
||||
service_request = ProductImageRequest(
|
||||
product_name=request.product_name,
|
||||
product_description=request.product_description,
|
||||
environment=request.environment,
|
||||
background_style=request.background_style,
|
||||
lighting=request.lighting,
|
||||
product_variant=request.product_variant,
|
||||
angle=request.angle,
|
||||
style=request.style,
|
||||
resolution=request.resolution,
|
||||
num_variations=request.num_variations,
|
||||
brand_colors=request.brand_colors,
|
||||
additional_context=request.additional_context,
|
||||
)
|
||||
|
||||
# Generate product image
|
||||
result = await product_image_service.generate_product_image(
|
||||
request=service_request,
|
||||
user_id=user_id,
|
||||
brand_context=brand_context,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error or "Product image generation failed")
|
||||
|
||||
logger.info(f"[Product Marketing] ✅ Generated product image: {result.asset_id}")
|
||||
|
||||
# Return result (image_bytes will be served via separate endpoint)
|
||||
return {
|
||||
"success": True,
|
||||
"product_name": result.product_name,
|
||||
"image_url": result.image_url,
|
||||
"asset_id": result.asset_id,
|
||||
"provider": result.provider,
|
||||
"model": result.model,
|
||||
"cost": result.cost,
|
||||
"generation_time": result.generation_time,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error generating product image: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Product image generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/products/images/{filename}", summary="Serve Product Image")
|
||||
async def serve_product_image(
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Serve generated product images."""
|
||||
try:
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
|
||||
_require_user_id(current_user, "serving product image")
|
||||
|
||||
# Locate image file
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
image_path = base_dir / "product_images" / filename
|
||||
|
||||
if not image_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
return FileResponse(
|
||||
path=str(image_path),
|
||||
media_type="image/png",
|
||||
filename=filename
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Product Marketing] ❌ Error serving product image: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ====================
|
||||
# HEALTH CHECK
|
||||
# ====================
|
||||
|
||||
@router.get("/health", summary="Health Check")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Product Marketing Suite."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "product_marketing",
|
||||
"version": "1.0.0",
|
||||
"modules": {
|
||||
"orchestrator": "available",
|
||||
"prompt_builder": "available",
|
||||
"brand_dna_sync": "available",
|
||||
"asset_audit": "available",
|
||||
"channel_pack": "available",
|
||||
"product_image_service": "available",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user