AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
17
backend/services/campaign_creator/__init__.py
Normal file
17
backend/services/campaign_creator/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Campaign Creator service package."""
|
||||
|
||||
from .orchestrator import CampaignOrchestrator, CampaignBlueprint, CampaignAssetNode
|
||||
from .campaign_storage import CampaignStorageService
|
||||
from .channel_pack import ChannelPackService
|
||||
from .asset_audit import AssetAuditService
|
||||
from .prompt_builder import CampaignPromptBuilder
|
||||
|
||||
__all__ = [
|
||||
"CampaignOrchestrator",
|
||||
"CampaignBlueprint",
|
||||
"CampaignAssetNode",
|
||||
"CampaignStorageService",
|
||||
"ChannelPackService",
|
||||
"AssetAuditService",
|
||||
"CampaignPromptBuilder",
|
||||
]
|
||||
204
backend/services/campaign_creator/asset_audit.py
Normal file
204
backend/services/campaign_creator/asset_audit.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Asset Audit Service
|
||||
Analyzes uploaded assets and recommends enhancement operations.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class AssetAuditService:
|
||||
"""Service to audit assets and recommend enhancements."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Asset Audit Service."""
|
||||
self.logger = logger
|
||||
logger.info("[Asset Audit] Service initialized")
|
||||
|
||||
def audit_asset(
|
||||
self,
|
||||
image_base64: str,
|
||||
asset_metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Audit an uploaded asset and recommend enhancement operations.
|
||||
|
||||
Args:
|
||||
image_base64: Base64 encoded image
|
||||
asset_metadata: Optional metadata about the asset
|
||||
|
||||
Returns:
|
||||
Audit results with recommendations
|
||||
"""
|
||||
try:
|
||||
# Decode image
|
||||
image_bytes = self._decode_base64(image_base64)
|
||||
if not image_bytes:
|
||||
raise ValueError("Invalid image data")
|
||||
|
||||
# Analyze image
|
||||
image = Image.open(BytesIO(image_bytes))
|
||||
width, height = image.size
|
||||
format_type = image.format or "PNG"
|
||||
mode = image.mode
|
||||
|
||||
# Basic quality checks
|
||||
quality_score = self._assess_quality(image, width, height)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = []
|
||||
|
||||
# Resolution recommendations
|
||||
if width < 1080 or height < 1080:
|
||||
recommendations.append({
|
||||
"operation": "upscale",
|
||||
"priority": "high",
|
||||
"reason": f"Image resolution ({width}x{height}) is below recommended 1080p for social media",
|
||||
"suggested_mode": "fast" if width < 512 else "conservative",
|
||||
})
|
||||
|
||||
# Background recommendations
|
||||
if mode == "RGBA" and self._has_transparency(image):
|
||||
recommendations.append({
|
||||
"operation": "remove_background",
|
||||
"priority": "low",
|
||||
"reason": "Image already has transparency, background removal may not be needed",
|
||||
})
|
||||
else:
|
||||
recommendations.append({
|
||||
"operation": "remove_background",
|
||||
"priority": "medium",
|
||||
"reason": "Background removal can create versatile product images",
|
||||
})
|
||||
|
||||
# Enhancement recommendations based on quality
|
||||
if quality_score < 0.7:
|
||||
recommendations.append({
|
||||
"operation": "enhance",
|
||||
"priority": "high",
|
||||
"reason": f"Image quality score ({quality_score:.2f}) suggests enhancement needed",
|
||||
"suggested_operations": ["upscale", "general_edit"],
|
||||
})
|
||||
|
||||
# Format recommendations
|
||||
if format_type not in ["PNG", "JPEG"]:
|
||||
recommendations.append({
|
||||
"operation": "convert",
|
||||
"priority": "low",
|
||||
"reason": f"Format {format_type} may not be optimal for web/social media",
|
||||
"suggested_format": "PNG" if mode == "RGBA" else "JPEG",
|
||||
})
|
||||
|
||||
audit_result = {
|
||||
"asset_info": {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": format_type,
|
||||
"mode": mode,
|
||||
"quality_score": quality_score,
|
||||
},
|
||||
"recommendations": recommendations,
|
||||
"status": "usable" if quality_score > 0.6 else "needs_enhancement",
|
||||
}
|
||||
|
||||
logger.info(f"[Asset Audit] Audited asset: {width}x{height}, quality: {quality_score:.2f}")
|
||||
return audit_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Asset Audit] Error auditing asset: {str(e)}")
|
||||
return {
|
||||
"asset_info": {},
|
||||
"recommendations": [],
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _decode_base64(self, image_base64: str) -> Optional[bytes]:
|
||||
"""Decode base64 image data."""
|
||||
try:
|
||||
if image_base64.startswith("data:"):
|
||||
_, b64data = image_base64.split(",", 1)
|
||||
else:
|
||||
b64data = image_base64
|
||||
return base64.b64decode(b64data)
|
||||
except Exception as e:
|
||||
logger.error(f"[Asset Audit] Error decoding base64: {str(e)}")
|
||||
return None
|
||||
|
||||
def _has_transparency(self, image: Image.Image) -> bool:
|
||||
"""Check if image has transparency."""
|
||||
if image.mode in ("RGBA", "LA"):
|
||||
alpha = image.split()[-1]
|
||||
return any(pixel < 255 for pixel in alpha.getdata())
|
||||
return False
|
||||
|
||||
def _assess_quality(self, image: Image.Image, width: int, height: int) -> float:
|
||||
"""
|
||||
Assess image quality score (0.0 to 1.0).
|
||||
|
||||
Simple heuristic based on resolution and format.
|
||||
"""
|
||||
score = 0.5 # Base score
|
||||
|
||||
# Resolution scoring
|
||||
min_dimension = min(width, height)
|
||||
if min_dimension >= 1080:
|
||||
score += 0.3
|
||||
elif min_dimension >= 512:
|
||||
score += 0.2
|
||||
elif min_dimension >= 256:
|
||||
score += 0.1
|
||||
|
||||
# Format scoring
|
||||
if image.format in ["PNG", "JPEG"]:
|
||||
score += 0.1
|
||||
|
||||
# Mode scoring
|
||||
if image.mode in ["RGB", "RGBA"]:
|
||||
score += 0.1
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
def batch_audit_assets(
|
||||
self,
|
||||
assets: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Audit multiple assets in batch.
|
||||
|
||||
Args:
|
||||
assets: List of asset dictionaries with 'image_base64' and optional 'metadata'
|
||||
|
||||
Returns:
|
||||
Batch audit results
|
||||
"""
|
||||
results = []
|
||||
for asset in assets:
|
||||
audit_result = self.audit_asset(
|
||||
asset.get('image_base64'),
|
||||
asset.get('metadata')
|
||||
)
|
||||
results.append({
|
||||
"asset_id": asset.get('id'),
|
||||
"audit": audit_result,
|
||||
})
|
||||
|
||||
# Summary statistics
|
||||
total_assets = len(results)
|
||||
usable_count = sum(1 for r in results if r["audit"]["status"] == "usable")
|
||||
needs_enhancement_count = sum(
|
||||
1 for r in results if r["audit"]["status"] == "needs_enhancement"
|
||||
)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"summary": {
|
||||
"total_assets": total_assets,
|
||||
"usable": usable_count,
|
||||
"needs_enhancement": needs_enhancement_count,
|
||||
"error": total_assets - usable_count - needs_enhancement_count,
|
||||
},
|
||||
}
|
||||
295
backend/services/campaign_creator/campaign_storage.py
Normal file
295
backend/services/campaign_creator/campaign_storage.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Campaign Storage Service
|
||||
Handles database persistence for campaigns, proposals, and assets.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
from models.product_marketing_models import Campaign, CampaignProposal, CampaignAsset, CampaignStatus
|
||||
from services.database import SessionLocal
|
||||
|
||||
|
||||
class CampaignStorageService:
|
||||
"""Service for storing and retrieving campaigns from database."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Campaign Storage Service."""
|
||||
self.logger = logger
|
||||
logger.info("[Campaign Storage] Service initialized")
|
||||
|
||||
def save_campaign(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_data: Dict[str, Any]
|
||||
) -> Campaign:
|
||||
"""
|
||||
Save campaign blueprint to database.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
campaign_data: Campaign blueprint data
|
||||
|
||||
Returns:
|
||||
Saved Campaign object
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
campaign_id = campaign_data.get('campaign_id')
|
||||
|
||||
# Check if campaign exists
|
||||
existing = db.query(Campaign).filter(
|
||||
Campaign.campaign_id == campaign_id,
|
||||
Campaign.user_id == user_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing campaign
|
||||
existing.campaign_name = campaign_data.get('campaign_name', existing.campaign_name)
|
||||
existing.goal = campaign_data.get('goal', existing.goal)
|
||||
existing.kpi = campaign_data.get('kpi', existing.kpi)
|
||||
existing.status = campaign_data.get('status', existing.status)
|
||||
existing.phases = campaign_data.get('phases', existing.phases)
|
||||
existing.channels = campaign_data.get('channels', existing.channels)
|
||||
existing.asset_nodes = campaign_data.get('asset_nodes', existing.asset_nodes)
|
||||
existing.product_context = campaign_data.get('product_context', existing.product_context)
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
logger.info(f"[Campaign Storage] Updated campaign {campaign_id}")
|
||||
return existing
|
||||
else:
|
||||
# Create new campaign
|
||||
campaign = Campaign(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
campaign_name=campaign_data.get('campaign_name'),
|
||||
goal=campaign_data.get('goal'),
|
||||
kpi=campaign_data.get('kpi'),
|
||||
status=campaign_data.get('status', 'draft'),
|
||||
phases=campaign_data.get('phases'),
|
||||
channels=campaign_data.get('channels', []),
|
||||
asset_nodes=campaign_data.get('asset_nodes', []),
|
||||
product_context=campaign_data.get('product_context'),
|
||||
)
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
logger.info(f"[Campaign Storage] Saved new campaign {campaign_id}")
|
||||
return campaign
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[Campaign Storage] Error saving campaign: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_campaign(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_id: str
|
||||
) -> Optional[Campaign]:
|
||||
"""Get campaign by ID."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.campaign_id == campaign_id,
|
||||
Campaign.user_id == user_id
|
||||
).first()
|
||||
return campaign
|
||||
except Exception as e:
|
||||
logger.error(f"[Campaign Storage] Error getting campaign: {str(e)}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def list_campaigns(
|
||||
self,
|
||||
user_id: str,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Campaign]:
|
||||
"""List campaigns for user."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(Campaign).filter(Campaign.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Campaign.status == status)
|
||||
|
||||
campaigns = query.order_by(desc(Campaign.created_at)).limit(limit).all()
|
||||
return campaigns
|
||||
except Exception as e:
|
||||
logger.error(f"[Campaign Storage] Error listing campaigns: {str(e)}")
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def save_proposals(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_id: str,
|
||||
proposals: Dict[str, Any]
|
||||
) -> List[CampaignProposal]:
|
||||
"""Save asset proposals for a campaign."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Delete existing proposals for this campaign
|
||||
db.query(CampaignProposal).filter(
|
||||
CampaignProposal.campaign_id == campaign_id,
|
||||
CampaignProposal.user_id == user_id
|
||||
).delete()
|
||||
|
||||
# Create new proposals
|
||||
saved_proposals = []
|
||||
for asset_id, proposal_data in proposals.get('proposals', {}).items():
|
||||
proposal = CampaignProposal(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
asset_node_id=asset_id,
|
||||
asset_type=proposal_data.get('asset_type'),
|
||||
channel=proposal_data.get('channel'),
|
||||
proposed_prompt=proposal_data.get('proposed_prompt'),
|
||||
recommended_template=proposal_data.get('recommended_template'),
|
||||
recommended_provider=proposal_data.get('recommended_provider'),
|
||||
recommended_model=proposal_data.get('recommended_model'),
|
||||
cost_estimate=proposal_data.get('cost_estimate', 0.0),
|
||||
concept_summary=proposal_data.get('concept_summary'),
|
||||
status='proposed',
|
||||
)
|
||||
db.add(proposal)
|
||||
saved_proposals.append(proposal)
|
||||
|
||||
db.commit()
|
||||
for proposal in saved_proposals:
|
||||
db.refresh(proposal)
|
||||
|
||||
logger.info(f"[Campaign Storage] Saved {len(saved_proposals)} proposals for campaign {campaign_id}")
|
||||
return saved_proposals
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[Campaign Storage] Error saving proposals: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_proposals(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_id: str
|
||||
) -> List[CampaignProposal]:
|
||||
"""Get proposals for a campaign."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
proposals = db.query(CampaignProposal).filter(
|
||||
CampaignProposal.campaign_id == campaign_id,
|
||||
CampaignProposal.user_id == user_id
|
||||
).all()
|
||||
return proposals
|
||||
except Exception as e:
|
||||
logger.error(f"[Campaign Storage] Error getting proposals: {str(e)}")
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def update_campaign_status(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_id: str,
|
||||
status: str
|
||||
) -> bool:
|
||||
"""Update campaign status."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
campaign = db.query(Campaign).filter(
|
||||
Campaign.campaign_id == campaign_id,
|
||||
Campaign.user_id == user_id
|
||||
).first()
|
||||
|
||||
if campaign:
|
||||
campaign.status = status
|
||||
db.commit()
|
||||
logger.info(f"[Campaign Storage] Updated campaign {campaign_id} status to {status}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[Campaign Storage] Error updating status: {str(e)}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def update_asset_status(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_id: str,
|
||||
asset_id: str,
|
||||
status: str,
|
||||
generated_asset_id: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Update status of a campaign asset and its proposal.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
campaign_id: Campaign ID
|
||||
asset_id: Asset node ID
|
||||
status: New status (generating, ready, approved, rejected)
|
||||
generated_asset_id: Optional Asset Library ID
|
||||
|
||||
Returns:
|
||||
True if updated successfully
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Update proposal status
|
||||
proposal = db.query(CampaignProposal).filter(
|
||||
CampaignProposal.campaign_id == campaign_id,
|
||||
CampaignProposal.user_id == user_id,
|
||||
CampaignProposal.asset_node_id == asset_id
|
||||
).first()
|
||||
|
||||
if proposal:
|
||||
proposal.status = status
|
||||
if generated_asset_id:
|
||||
proposal.generated_asset_id = generated_asset_id
|
||||
db.commit()
|
||||
logger.info(f"[Campaign Storage] Updated proposal {asset_id} status to {status}")
|
||||
|
||||
# Update or create campaign asset
|
||||
campaign_asset = db.query(CampaignAsset).filter(
|
||||
CampaignAsset.campaign_id == campaign_id,
|
||||
CampaignAsset.user_id == user_id,
|
||||
CampaignAsset.asset_node_id == asset_id
|
||||
).first()
|
||||
|
||||
if campaign_asset:
|
||||
campaign_asset.status = status
|
||||
if generated_asset_id:
|
||||
campaign_asset.generated_asset_id = generated_asset_id
|
||||
db.commit()
|
||||
logger.info(f"[Campaign Storage] Updated campaign asset {asset_id} status to {status}")
|
||||
else:
|
||||
# Create new campaign asset if it doesn't exist
|
||||
if proposal:
|
||||
campaign_asset = CampaignAsset(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
asset_node_id=asset_id,
|
||||
asset_type=proposal.asset_type,
|
||||
channel=proposal.channel,
|
||||
status=status,
|
||||
generated_asset_id=generated_asset_id,
|
||||
)
|
||||
db.add(campaign_asset)
|
||||
db.commit()
|
||||
logger.info(f"[Campaign Storage] Created campaign asset {asset_id}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[Campaign Storage] Error updating asset status: {str(e)}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
179
backend/services/campaign_creator/channel_pack.py
Normal file
179
backend/services/campaign_creator/channel_pack.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Channel Pack Service
|
||||
Maps channels to templates, copy frameworks, and platform-specific optimizations.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
from services.image_studio.templates import Platform, TemplateManager
|
||||
from services.image_studio.social_optimizer_service import SocialOptimizerService
|
||||
|
||||
|
||||
class ChannelPackService:
|
||||
"""Service to build channel-specific asset packs."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Channel Pack Service."""
|
||||
self.template_manager = TemplateManager()
|
||||
self.social_optimizer = SocialOptimizerService()
|
||||
self.logger = logger
|
||||
logger.info("[Channel Pack] Service initialized")
|
||||
|
||||
def get_channel_pack(
|
||||
self,
|
||||
channel: str,
|
||||
asset_type: str = "social_post"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get channel-specific pack configuration.
|
||||
|
||||
Args:
|
||||
channel: Target channel (instagram, linkedin, tiktok, facebook, twitter, pinterest, youtube)
|
||||
asset_type: Type of asset (social_post, story, reel, cover, etc.)
|
||||
|
||||
Returns:
|
||||
Channel pack configuration with templates, dimensions, copy frameworks
|
||||
"""
|
||||
try:
|
||||
# Map channel string to Platform enum
|
||||
platform_map = {
|
||||
'instagram': Platform.INSTAGRAM,
|
||||
'linkedin': Platform.LINKEDIN,
|
||||
'tiktok': Platform.TIKTOK,
|
||||
'facebook': Platform.FACEBOOK,
|
||||
'twitter': Platform.TWITTER,
|
||||
'pinterest': Platform.PINTEREST,
|
||||
'youtube': Platform.YOUTUBE,
|
||||
}
|
||||
|
||||
platform = platform_map.get(channel.lower())
|
||||
if not platform:
|
||||
raise ValueError(f"Unsupported channel: {channel}")
|
||||
|
||||
# Get templates for this platform
|
||||
templates = self.template_manager.get_platform_templates().get(platform, [])
|
||||
|
||||
# Get platform formats
|
||||
formats = self.social_optimizer.get_platform_formats(platform)
|
||||
|
||||
# Build channel pack
|
||||
pack = {
|
||||
"channel": channel,
|
||||
"platform": platform.value,
|
||||
"asset_type": asset_type,
|
||||
"templates": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"dimensions": f"{t.aspect_ratio.width}x{t.aspect_ratio.height}",
|
||||
"aspect_ratio": t.aspect_ratio.ratio,
|
||||
"recommended_provider": t.recommended_provider,
|
||||
"quality": t.quality,
|
||||
}
|
||||
for t in templates
|
||||
],
|
||||
"formats": formats,
|
||||
"copy_framework": self._get_copy_framework(channel, asset_type),
|
||||
"optimization_tips": self._get_optimization_tips(channel),
|
||||
}
|
||||
|
||||
logger.info(f"[Channel Pack] Built pack for {channel} ({asset_type})")
|
||||
return pack
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Channel Pack] Error building pack: {str(e)}")
|
||||
return {
|
||||
"channel": channel,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def _get_copy_framework(
|
||||
self,
|
||||
channel: str,
|
||||
asset_type: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Get copy framework for channel and asset type."""
|
||||
frameworks = {
|
||||
"instagram": {
|
||||
"social_post": {
|
||||
"caption_length": "125-150 words optimal",
|
||||
"hashtags": "5-10 relevant hashtags",
|
||||
"cta": "Clear call-to-action in first line",
|
||||
"emoji": "Use 1-3 emojis strategically",
|
||||
},
|
||||
"story": {
|
||||
"text_overlay": "Keep text minimal, readable at small size",
|
||||
"cta": "Swipe-up or link sticker",
|
||||
},
|
||||
},
|
||||
"linkedin": {
|
||||
"social_post": {
|
||||
"length": "150-300 words for maximum engagement",
|
||||
"hashtags": "3-5 professional hashtags",
|
||||
"tone": "Professional, thought-leadership focused",
|
||||
"cta": "Engage with question or call-to-action",
|
||||
},
|
||||
},
|
||||
"tiktok": {
|
||||
"video": {
|
||||
"hook": "Strong hook in first 3 seconds",
|
||||
"caption": "Short, engaging, use trending hashtags",
|
||||
"hashtags": "3-5 trending hashtags",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return frameworks.get(channel, {}).get(asset_type, {})
|
||||
|
||||
def _get_optimization_tips(self, channel: str) -> List[str]:
|
||||
"""Get optimization tips for channel."""
|
||||
tips = {
|
||||
"instagram": [
|
||||
"Use square (1:1) or portrait (4:5) for feed posts",
|
||||
"Include text overlay safe zones (15% top/bottom, 10% left/right)",
|
||||
"Optimize for mobile viewing",
|
||||
],
|
||||
"linkedin": [
|
||||
"Use landscape (1.91:1) for feed posts",
|
||||
"Professional photography style",
|
||||
"Include clear value proposition",
|
||||
],
|
||||
"tiktok": [
|
||||
"Vertical format (9:16) required",
|
||||
"Eye-catching first frame",
|
||||
"Fast-paced, engaging content",
|
||||
],
|
||||
}
|
||||
|
||||
return tips.get(channel, [])
|
||||
|
||||
def build_multi_channel_pack(
|
||||
self,
|
||||
channels: List[str],
|
||||
source_image_base64: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build optimized asset pack for multiple channels from single source.
|
||||
|
||||
Args:
|
||||
channels: List of target channels
|
||||
source_image_base64: Source image to optimize
|
||||
|
||||
Returns:
|
||||
Multi-channel pack with optimized variants
|
||||
"""
|
||||
pack_results = []
|
||||
|
||||
for channel in channels:
|
||||
pack = self.get_channel_pack(channel)
|
||||
pack_results.append({
|
||||
"channel": channel,
|
||||
"pack": pack,
|
||||
})
|
||||
|
||||
return {
|
||||
"source_image": "provided",
|
||||
"channels": pack_results,
|
||||
"total_variants": len(channels),
|
||||
}
|
||||
653
backend/services/campaign_creator/orchestrator.py
Normal file
653
backend/services/campaign_creator/orchestrator.py
Normal file
@@ -0,0 +1,653 @@
|
||||
"""
|
||||
Campaign Creator Orchestrator
|
||||
Main service that orchestrates campaign workflows and asset generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from loguru import logger
|
||||
|
||||
from services.image_studio import ImageStudioManager, CreateStudioRequest
|
||||
from .prompt_builder import CampaignPromptBuilder
|
||||
from services.product_marketing.brand_dna_sync import BrandDNASyncService
|
||||
from .asset_audit import AssetAuditService
|
||||
from .channel_pack import ChannelPackService
|
||||
from services.database import SessionLocal
|
||||
from services.subscription import PricingService
|
||||
from services.subscription.preflight_validator import validate_image_generation_operations
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignAssetNode:
|
||||
"""Represents an asset node in the campaign graph."""
|
||||
asset_id: str
|
||||
asset_type: str # image, video, text, audio
|
||||
channel: str
|
||||
status: str # draft, generating, ready, approved
|
||||
prompt: Optional[str] = None
|
||||
template_id: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
cost_estimate: Optional[float] = None
|
||||
generated_asset_id: Optional[int] = None # Asset Library ID
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignBlueprint:
|
||||
"""Campaign blueprint with phases and asset nodes."""
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
goal: str
|
||||
kpi: Optional[str] = None
|
||||
phases: List[Dict[str, Any]] = None # teaser, launch, nurture
|
||||
asset_nodes: List[CampaignAssetNode] = None
|
||||
channels: List[str] = None
|
||||
status: str = "draft" # draft, generating, ready, published
|
||||
|
||||
|
||||
class CampaignOrchestrator:
|
||||
"""Main orchestrator for Campaign Creator."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Campaign Orchestrator."""
|
||||
self.image_studio = ImageStudioManager()
|
||||
self.prompt_builder = CampaignPromptBuilder()
|
||||
self.brand_dna_sync = BrandDNASyncService()
|
||||
self.asset_audit = AssetAuditService()
|
||||
self.channel_pack = ChannelPackService()
|
||||
self.logger = logger
|
||||
logger.info("[Campaign Orchestrator] Initialized")
|
||||
|
||||
def create_campaign_blueprint(
|
||||
self,
|
||||
user_id: str,
|
||||
campaign_data: Dict[str, Any]
|
||||
) -> CampaignBlueprint:
|
||||
"""
|
||||
Create campaign blueprint from user input and onboarding data.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
campaign_data: Campaign information (name, goal, channels, etc.)
|
||||
|
||||
Returns:
|
||||
Campaign blueprint with asset nodes
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
campaign_id = campaign_data.get('campaign_id') or f"campaign_{user_id}_{int(time.time())}"
|
||||
campaign_name = campaign_data.get('campaign_name', 'New Campaign')
|
||||
goal = campaign_data.get('goal', 'product_launch')
|
||||
channels = campaign_data.get('channels', [])
|
||||
|
||||
# Get brand DNA for personalization
|
||||
brand_dna = self.brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
|
||||
# Build campaign phases
|
||||
phases = self._build_campaign_phases(goal, channels)
|
||||
|
||||
# Generate asset nodes for each phase and channel
|
||||
asset_nodes = []
|
||||
for phase in phases:
|
||||
phase_name = phase.get('name')
|
||||
for channel in channels:
|
||||
# Determine required assets for this phase + channel
|
||||
required_assets = self._get_required_assets(phase_name, channel)
|
||||
|
||||
for asset_type in required_assets:
|
||||
asset_node = CampaignAssetNode(
|
||||
asset_id=f"{campaign_id}_{phase_name}_{channel}_{asset_type}",
|
||||
asset_type=asset_type,
|
||||
channel=channel,
|
||||
status="draft",
|
||||
)
|
||||
asset_nodes.append(asset_node)
|
||||
|
||||
blueprint = CampaignBlueprint(
|
||||
campaign_id=campaign_id,
|
||||
campaign_name=campaign_name,
|
||||
goal=goal,
|
||||
kpi=campaign_data.get('kpi'),
|
||||
phases=phases,
|
||||
asset_nodes=asset_nodes,
|
||||
channels=channels,
|
||||
status="draft",
|
||||
)
|
||||
|
||||
logger.info(f"[Orchestrator] Created blueprint for campaign {campaign_id} with {len(asset_nodes)} assets")
|
||||
return blueprint
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Error creating blueprint: {str(e)}")
|
||||
raise
|
||||
|
||||
def generate_asset_proposals(
|
||||
self,
|
||||
user_id: str,
|
||||
blueprint: CampaignBlueprint,
|
||||
product_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate AI proposals for each asset node in the blueprint.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
blueprint: Campaign blueprint
|
||||
product_context: Product information
|
||||
|
||||
Returns:
|
||||
Dictionary with proposals for each asset node
|
||||
"""
|
||||
try:
|
||||
proposals = {}
|
||||
|
||||
for asset_node in blueprint.asset_nodes:
|
||||
# Build specialized prompt based on asset type and channel
|
||||
if asset_node.asset_type == "image":
|
||||
base_prompt = product_context.get('product_description', 'Product image') if product_context else 'Marketing image'
|
||||
enhanced_prompt = self.prompt_builder.build_marketing_image_prompt(
|
||||
base_prompt=base_prompt,
|
||||
user_id=user_id,
|
||||
channel=asset_node.channel,
|
||||
asset_type="hero_image",
|
||||
product_context=product_context,
|
||||
)
|
||||
|
||||
# Get channel pack for template recommendations
|
||||
channel_pack = self.channel_pack.get_channel_pack(asset_node.channel)
|
||||
recommended_template = channel_pack.get('templates', [{}])[0] if channel_pack.get('templates') else None
|
||||
|
||||
# Estimate cost
|
||||
cost_estimate = self._estimate_asset_cost("image", asset_node.channel)
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id, # Include campaign_id for tracking
|
||||
"proposed_prompt": enhanced_prompt,
|
||||
"recommended_template": recommended_template.get('id') if recommended_template else None,
|
||||
"recommended_provider": recommended_template.get('recommended_provider', 'wavespeed') if recommended_template else 'wavespeed',
|
||||
"cost_estimate": cost_estimate,
|
||||
"concept_summary": self._generate_concept_summary(enhanced_prompt),
|
||||
}
|
||||
|
||||
elif asset_node.asset_type == "video":
|
||||
# Video asset proposals - determine if animation (image-to-video) or demo (text-to-video)
|
||||
# Default to animation if we have product image, otherwise demo
|
||||
video_subtype = asset_proposal.get('video_subtype', 'animation') if 'asset_proposal' in locals() else 'demo'
|
||||
|
||||
# For demo videos (text-to-video), we need product description
|
||||
if video_subtype == "demo" or not product_context or not product_context.get('product_image_base64'):
|
||||
# Text-to-video demo video
|
||||
video_type = "demo" # Default, can be customized
|
||||
if asset_node.channel in ["tiktok", "instagram"]:
|
||||
video_type = "storytelling" # Storytelling for social media
|
||||
elif asset_node.channel in ["linkedin", "youtube"]:
|
||||
video_type = "feature_highlight" # Feature highlights for professional
|
||||
|
||||
# Estimate cost for text-to-video (WAN 2.5: $0.05-$0.15/second)
|
||||
duration = 10 # Default 10s for demo videos
|
||||
resolution = "720p" # Default
|
||||
cost_per_second = 0.10 if resolution == "720p" else (0.15 if resolution == "1080p" else 0.05)
|
||||
cost_estimate = duration * cost_per_second
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"video_subtype": "demo", # Text-to-video
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id,
|
||||
"video_type": video_type,
|
||||
"duration": duration,
|
||||
"resolution": resolution,
|
||||
"cost_estimate": cost_estimate,
|
||||
"concept_summary": f"Product {video_type} video optimized for {asset_node.channel}",
|
||||
"note": "Text-to-video demo - requires product description",
|
||||
}
|
||||
else:
|
||||
# Image-to-video animation
|
||||
animation_type = "reveal" # Default
|
||||
if asset_node.channel in ["tiktok", "instagram", "youtube"]:
|
||||
animation_type = "demo" # Demo animations for social media
|
||||
elif asset_node.channel in ["linkedin", "facebook"]:
|
||||
animation_type = "reveal" # Professional reveal for B2B
|
||||
|
||||
# Estimate cost for image-to-video (WAN 2.5: $0.05-$0.15/second)
|
||||
duration = 5 # Default 5s for animations
|
||||
resolution = "720p" # Default
|
||||
cost_per_second = 0.10 if resolution == "720p" else (0.15 if resolution == "1080p" else 0.05)
|
||||
cost_estimate = duration * cost_per_second
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"video_subtype": "animation", # Image-to-video
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id,
|
||||
"animation_type": animation_type,
|
||||
"duration": duration,
|
||||
"resolution": resolution,
|
||||
"cost_estimate": cost_estimate,
|
||||
"concept_summary": f"Product {animation_type} animation optimized for {asset_node.channel}",
|
||||
"note": "Requires product image - will be provided during generation",
|
||||
}
|
||||
|
||||
elif asset_node.asset_type == "text":
|
||||
base_request = f"Write {asset_node.channel} {asset_node.asset_type} for product launch"
|
||||
enhanced_prompt = self.prompt_builder.build_marketing_copy_prompt(
|
||||
base_request=base_request,
|
||||
user_id=user_id,
|
||||
channel=asset_node.channel,
|
||||
content_type="caption",
|
||||
product_context=product_context,
|
||||
)
|
||||
|
||||
proposals[asset_node.asset_id] = {
|
||||
"asset_id": asset_node.asset_id,
|
||||
"asset_type": asset_node.asset_type,
|
||||
"channel": asset_node.channel,
|
||||
"campaign_id": blueprint.campaign_id, # Include campaign_id for tracking
|
||||
"proposed_prompt": enhanced_prompt,
|
||||
"cost_estimate": 0.0, # Text generation cost is minimal
|
||||
"concept_summary": "Marketing copy optimized for channel and persona",
|
||||
}
|
||||
|
||||
logger.info(f"[Orchestrator] Generated {len(proposals)} asset proposals")
|
||||
return {"proposals": proposals, "total_assets": len(proposals)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Error generating proposals: {str(e)}")
|
||||
raise
|
||||
|
||||
async def generate_asset(
|
||||
self,
|
||||
user_id: str,
|
||||
asset_proposal: Dict[str, Any],
|
||||
product_context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a single asset using Image Studio APIs.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
asset_proposal: Asset proposal from generate_asset_proposals
|
||||
product_context: Product information
|
||||
|
||||
Returns:
|
||||
Generated asset result
|
||||
"""
|
||||
try:
|
||||
asset_type = asset_proposal.get('asset_type')
|
||||
|
||||
if asset_type == "image":
|
||||
# Build CreateStudioRequest
|
||||
create_request = CreateStudioRequest(
|
||||
prompt=asset_proposal.get('proposed_prompt'),
|
||||
template_id=asset_proposal.get('recommended_template'),
|
||||
provider=asset_proposal.get('recommended_provider', 'wavespeed'),
|
||||
quality="premium",
|
||||
enhance_prompt=True,
|
||||
use_persona=True,
|
||||
num_variations=1,
|
||||
)
|
||||
|
||||
# Generate image using Image Studio
|
||||
result = await self.image_studio.create_image(create_request, user_id=user_id)
|
||||
|
||||
# Asset is automatically tracked in Asset Library via Image Studio
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "image",
|
||||
"result": result,
|
||||
"asset_library_ids": [
|
||||
r.get('asset_id') for r in result.get('results', [])
|
||||
if r.get('asset_id')
|
||||
],
|
||||
}
|
||||
|
||||
elif asset_type == "video":
|
||||
# Check video subtype: "animation" (image-to-video) or "demo" (text-to-video)
|
||||
video_subtype = asset_proposal.get('video_subtype', 'animation')
|
||||
|
||||
if video_subtype == "demo":
|
||||
# Text-to-video: Product demo video from description
|
||||
from services.product_marketing.product_video_service import ProductVideoService, ProductVideoRequest
|
||||
|
||||
# Get product info from context
|
||||
product_name = product_context.get('product_name', 'Product') if product_context else 'Product'
|
||||
product_description = product_context.get('product_description', '') if product_context else ''
|
||||
|
||||
if not product_description:
|
||||
raise ValueError("Product description required for text-to-video demo generation")
|
||||
|
||||
# Get brand context
|
||||
brand_dna = self.brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
brand_context = {
|
||||
"visual_identity": brand_dna.get("visual_identity", {}),
|
||||
"persona": brand_dna.get("persona", {}),
|
||||
}
|
||||
|
||||
# Get video type from proposal or default
|
||||
video_type = asset_proposal.get('video_type', 'demo')
|
||||
|
||||
# Create video service
|
||||
video_service = ProductVideoService()
|
||||
|
||||
# Create video request
|
||||
video_request = ProductVideoRequest(
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
video_type=video_type,
|
||||
resolution=asset_proposal.get('resolution', '720p'),
|
||||
duration=asset_proposal.get('duration', 10),
|
||||
audio_base64=asset_proposal.get('audio_base64'),
|
||||
brand_context=brand_context,
|
||||
additional_context=asset_proposal.get('additional_context'),
|
||||
)
|
||||
|
||||
# Generate video using unified ai_video_generate()
|
||||
result = await video_service.generate_product_video(video_request, user_id)
|
||||
|
||||
# Extract campaign_id for metadata
|
||||
campaign_id = asset_proposal.get('campaign_id')
|
||||
asset_id = asset_proposal.get('asset_id', '')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "video",
|
||||
"video_subtype": "demo",
|
||||
"video_url": result.get('file_url'),
|
||||
"video_filename": result.get('filename'),
|
||||
"cost": result.get('cost', 0.0),
|
||||
"video_type": video_type,
|
||||
"campaign_id": campaign_id,
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
|
||||
else:
|
||||
# Image-to-video: Product animation
|
||||
from services.product_marketing.product_animation_service import ProductAnimationService, ProductAnimationRequest
|
||||
|
||||
# Get product image from proposal or product context
|
||||
product_image_base64 = asset_proposal.get('product_image_base64')
|
||||
if not product_image_base64 and product_context:
|
||||
product_image_base64 = product_context.get('product_image_base64')
|
||||
|
||||
if not product_image_base64:
|
||||
raise ValueError("Product image required for image-to-video animation generation")
|
||||
|
||||
# Get animation type from proposal or default to "reveal"
|
||||
animation_type = asset_proposal.get('animation_type', 'reveal')
|
||||
product_name = product_context.get('product_name', 'Product') if product_context else 'Product'
|
||||
product_description = product_context.get('product_description') if product_context else None
|
||||
|
||||
# Get brand context
|
||||
brand_dna = self.brand_dna_sync.get_brand_dna_tokens(user_id)
|
||||
brand_context = {
|
||||
"visual_identity": brand_dna.get("visual_identity", {}),
|
||||
"persona": brand_dna.get("persona", {}),
|
||||
}
|
||||
|
||||
# Create animation service
|
||||
animation_service = ProductAnimationService()
|
||||
|
||||
# Create animation request
|
||||
animation_request = ProductAnimationRequest(
|
||||
product_image_base64=product_image_base64,
|
||||
animation_type=animation_type,
|
||||
product_name=product_name,
|
||||
product_description=product_description,
|
||||
resolution=asset_proposal.get('resolution', '720p'),
|
||||
duration=asset_proposal.get('duration', 5),
|
||||
audio_base64=asset_proposal.get('audio_base64'),
|
||||
brand_context=brand_context,
|
||||
additional_context=asset_proposal.get('additional_context'),
|
||||
)
|
||||
|
||||
# Generate video
|
||||
result = await animation_service.animate_product(animation_request, user_id)
|
||||
|
||||
# Extract campaign_id for metadata
|
||||
campaign_id = asset_proposal.get('campaign_id')
|
||||
asset_id = asset_proposal.get('asset_id', '')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "video",
|
||||
"video_subtype": "animation",
|
||||
"video_url": result.get('video_url'),
|
||||
"video_filename": result.get('filename'),
|
||||
"cost": result.get('cost', 0.0),
|
||||
"animation_type": animation_type,
|
||||
"campaign_id": campaign_id,
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
|
||||
elif asset_type == "text":
|
||||
# Import text generation service and tracker
|
||||
import asyncio
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from utils.text_asset_tracker import save_and_track_text_content
|
||||
from services.database import SessionLocal
|
||||
|
||||
# Get enhanced prompt from proposal
|
||||
text_prompt = asset_proposal.get('proposed_prompt', '')
|
||||
channel = asset_proposal.get('channel', 'social')
|
||||
asset_id = asset_proposal.get('asset_id', '')
|
||||
|
||||
# Extract campaign_id - try from asset_proposal first, then from asset_id
|
||||
# asset_id format: {campaign_id}_{phase}_{channel}_{type}
|
||||
campaign_id = asset_proposal.get('campaign_id')
|
||||
if not campaign_id and asset_id and '_' in asset_id:
|
||||
# Try to extract: asset_id might be "campaign_user123_1234567890_teaser_instagram_text"
|
||||
# We need to find where phase_name starts (common phases: teaser, launch, nurture)
|
||||
parts = asset_id.split('_')
|
||||
# Find phase indicator (usually one of: teaser, launch, nurture)
|
||||
phase_indicators = ['teaser', 'launch', 'nurture', 'prelaunch', 'postlaunch']
|
||||
phase_idx = None
|
||||
for i, part in enumerate(parts):
|
||||
if part.lower() in phase_indicators:
|
||||
phase_idx = i
|
||||
break
|
||||
if phase_idx and phase_idx > 0:
|
||||
# Campaign ID is everything before the phase
|
||||
campaign_id = '_'.join(parts[:phase_idx])
|
||||
|
||||
# If still not found, use None (metadata will work without it)
|
||||
if not campaign_id:
|
||||
logger.warning(f"[Orchestrator] Could not extract campaign_id from asset_id: {asset_id}")
|
||||
|
||||
# Build system prompt for marketing copy
|
||||
system_prompt = f"""You are an expert marketing copywriter specializing in {channel} content.
|
||||
Generate compelling, on-brand marketing copy that:
|
||||
- Is optimized for {channel} platform best practices
|
||||
- Includes a clear call-to-action
|
||||
- Uses appropriate tone and style for the platform
|
||||
- Is concise and engaging
|
||||
- Aligns with the product marketing context provided
|
||||
|
||||
Return only the final copy text without explanations or markdown formatting."""
|
||||
|
||||
# Run synchronous llm_text_gen in thread pool
|
||||
logger.info(f"[Orchestrator] Generating text asset for channel: {channel}")
|
||||
generated_text = await asyncio.to_thread(
|
||||
llm_text_gen,
|
||||
prompt=text_prompt,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if not generated_text or not generated_text.strip():
|
||||
raise ValueError("Text generation returned empty content")
|
||||
|
||||
# Save to Asset Library
|
||||
db = SessionLocal()
|
||||
asset_library_id = None
|
||||
try:
|
||||
asset_library_id = save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=generated_text.strip(),
|
||||
source_module="campaign_creator",
|
||||
title=f"{channel.title()} Copy: {asset_id.split('_')[-1] if '_' in asset_id else 'Marketing Copy'}",
|
||||
description=f"Marketing copy for {channel} platform generated from campaign proposal",
|
||||
prompt=text_prompt,
|
||||
tags=["campaign_creator", channel.lower(), "text", "copy"],
|
||||
asset_metadata={
|
||||
"campaign_id": campaign_id,
|
||||
"asset_id": asset_id,
|
||||
"asset_type": "text",
|
||||
"channel": channel,
|
||||
"concept_summary": asset_proposal.get('concept_summary'),
|
||||
},
|
||||
subdirectory="campaigns",
|
||||
file_extension=".txt"
|
||||
)
|
||||
|
||||
if asset_library_id:
|
||||
logger.info(f"[Orchestrator] ✅ Text asset saved to library: ID={asset_library_id}")
|
||||
else:
|
||||
logger.warning(f"[Orchestrator] ⚠️ Text asset tracking returned None")
|
||||
|
||||
except Exception as save_error:
|
||||
logger.error(f"[Orchestrator] ⚠️ Failed to save text asset to library: {str(save_error)}")
|
||||
# Continue even if save fails - text is still generated
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"asset_type": "text",
|
||||
"content": generated_text.strip(),
|
||||
"asset_library_id": asset_library_id,
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported asset type: {asset_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Error generating asset: {str(e)}")
|
||||
raise
|
||||
|
||||
def validate_campaign_preflight(
|
||||
self,
|
||||
user_id: str,
|
||||
blueprint: CampaignBlueprint
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate campaign blueprint against subscription limits before generation.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
blueprint: Campaign blueprint
|
||||
|
||||
Returns:
|
||||
Pre-flight validation results
|
||||
"""
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
pricing_service = PricingService(db)
|
||||
|
||||
# Count operations needed
|
||||
image_count = sum(1 for node in blueprint.asset_nodes if node.asset_type == "image")
|
||||
text_count = sum(1 for node in blueprint.asset_nodes if node.asset_type == "text")
|
||||
|
||||
# Estimate total cost
|
||||
total_cost = 0.0
|
||||
for node in blueprint.asset_nodes:
|
||||
if node.cost_estimate:
|
||||
total_cost += node.cost_estimate
|
||||
|
||||
# Validate image generation limits
|
||||
operations = []
|
||||
if image_count > 0:
|
||||
operations.append({
|
||||
'provider': 'stability', # Default provider
|
||||
'tokens_requested': 0,
|
||||
'actual_provider_name': 'wavespeed',
|
||||
'operation_type': 'image_generation',
|
||||
})
|
||||
|
||||
can_proceed, message, error_details = pricing_service.check_comprehensive_limits(
|
||||
user_id=user_id,
|
||||
operations=operations * image_count if operations else []
|
||||
)
|
||||
|
||||
return {
|
||||
"can_proceed": can_proceed,
|
||||
"message": message,
|
||||
"error_details": error_details,
|
||||
"summary": {
|
||||
"total_assets": len(blueprint.asset_nodes),
|
||||
"image_count": image_count,
|
||||
"text_count": text_count,
|
||||
"estimated_cost": total_cost,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Error in pre-flight validation: {str(e)}")
|
||||
return {
|
||||
"can_proceed": False,
|
||||
"message": f"Validation error: {str(e)}",
|
||||
"error_details": {},
|
||||
}
|
||||
|
||||
def _build_campaign_phases(
|
||||
self,
|
||||
goal: str,
|
||||
channels: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build campaign phases based on goal."""
|
||||
if goal == "product_launch":
|
||||
return [
|
||||
{"name": "teaser", "duration_days": 7, "purpose": "Build anticipation"},
|
||||
{"name": "launch", "duration_days": 3, "purpose": "Official launch"},
|
||||
{"name": "nurture", "duration_days": 14, "purpose": "Sustain engagement"},
|
||||
]
|
||||
else:
|
||||
return [
|
||||
{"name": "campaign", "duration_days": 30, "purpose": "Campaign execution"},
|
||||
]
|
||||
|
||||
def _get_required_assets(
|
||||
self,
|
||||
phase: str,
|
||||
channel: str
|
||||
) -> List[str]:
|
||||
"""Get required asset types for phase and channel."""
|
||||
# Default: image for all phases and channels
|
||||
assets = ["image"]
|
||||
|
||||
# Add text/copy for social channels
|
||||
if channel in ["instagram", "linkedin", "facebook", "twitter"]:
|
||||
assets.append("text")
|
||||
|
||||
return assets
|
||||
|
||||
def _estimate_asset_cost(
|
||||
self,
|
||||
asset_type: str,
|
||||
channel: str
|
||||
) -> float:
|
||||
"""Estimate cost for asset generation."""
|
||||
if asset_type == "image":
|
||||
# Premium quality image: ~5-6 credits
|
||||
return 5.0
|
||||
elif asset_type == "video":
|
||||
# WAN 2.5 Image-to-Video: $0.05-$0.15/second
|
||||
# Default: 5 seconds at 720p = $0.50
|
||||
return 0.50
|
||||
elif asset_type == "text":
|
||||
return 0.0 # Text generation is typically included
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
def _generate_concept_summary(self, prompt: str) -> str:
|
||||
"""Generate a brief concept summary from prompt."""
|
||||
# Simple extraction: take first 100 chars
|
||||
return prompt[:100] + "..." if len(prompt) > 100 else prompt
|
||||
303
backend/services/campaign_creator/prompt_builder.py
Normal file
303
backend/services/campaign_creator/prompt_builder.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Campaign Creator Prompt Builder
|
||||
Extends AIPromptOptimizer with campaign-specific prompt enhancement.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
|
||||
from services.ai_prompt_optimizer import AIPromptOptimizer
|
||||
from services.onboarding import OnboardingDataService
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from services.persona_data_service import PersonaDataService
|
||||
from services.database import SessionLocal
|
||||
|
||||
|
||||
class CampaignPromptBuilder(AIPromptOptimizer):
|
||||
"""Specialized prompt builder for campaign assets with onboarding data integration."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Campaign Prompt Builder."""
|
||||
super().__init__()
|
||||
self.onboarding_data_service = OnboardingDataService()
|
||||
self.logger = logger
|
||||
logger.info("[Campaign Prompt Builder] Initialized")
|
||||
|
||||
def build_marketing_image_prompt(
|
||||
self,
|
||||
base_prompt: str,
|
||||
user_id: str,
|
||||
channel: Optional[str] = None,
|
||||
asset_type: str = "hero_image",
|
||||
product_context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Build enhanced marketing image prompt with brand DNA and persona data.
|
||||
|
||||
Args:
|
||||
base_prompt: Base product description or image concept
|
||||
user_id: User ID to fetch onboarding data
|
||||
channel: Target channel (instagram, linkedin, tiktok, etc.)
|
||||
asset_type: Type of asset (hero_image, product_photo, lifestyle, etc.)
|
||||
product_context: Additional product information
|
||||
|
||||
Returns:
|
||||
Enhanced prompt with brand DNA, persona style, and marketing context
|
||||
"""
|
||||
try:
|
||||
# Get onboarding data
|
||||
db = SessionLocal()
|
||||
try:
|
||||
onboarding_db = OnboardingDatabaseService(db)
|
||||
website_analysis = onboarding_db.get_website_analysis(user_id, db)
|
||||
persona_data = onboarding_db.get_persona_data(user_id, db)
|
||||
competitor_analyses = onboarding_db.get_competitor_analysis(user_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Build prompt layers
|
||||
enhanced_prompt = base_prompt
|
||||
|
||||
# Layer 1: Brand DNA (from website_analysis)
|
||||
if website_analysis:
|
||||
writing_style = website_analysis.get('writing_style', {})
|
||||
target_audience = website_analysis.get('target_audience', {})
|
||||
brand_analysis = website_analysis.get('brand_analysis', {})
|
||||
style_guidelines = website_analysis.get('style_guidelines', {})
|
||||
|
||||
# Add brand tone and style
|
||||
tone = writing_style.get('tone', 'professional')
|
||||
voice = writing_style.get('voice', 'authoritative')
|
||||
brand_enhancement = f", {tone} tone, {voice} voice"
|
||||
|
||||
# Add target audience context
|
||||
demographics = target_audience.get('demographics', [])
|
||||
if demographics:
|
||||
audience_context = f", targeting {', '.join(demographics[:2])}"
|
||||
enhanced_prompt += audience_context
|
||||
|
||||
# Add brand visual identity if available
|
||||
if brand_analysis:
|
||||
color_palette = brand_analysis.get('color_palette', [])
|
||||
if color_palette:
|
||||
colors = ', '.join(color_palette[:3])
|
||||
enhanced_prompt += f", brand colors: {colors}"
|
||||
|
||||
# Layer 2: Persona Visual Style (from persona_data)
|
||||
if persona_data:
|
||||
core_persona = persona_data.get('corePersona', {})
|
||||
platform_personas = persona_data.get('platformPersonas', {})
|
||||
|
||||
if core_persona:
|
||||
persona_name = core_persona.get('persona_name', '')
|
||||
archetype = core_persona.get('archetype', '')
|
||||
if persona_name:
|
||||
enhanced_prompt += f", {persona_name} style"
|
||||
|
||||
# Channel-specific persona adaptation
|
||||
if channel and platform_personas:
|
||||
platform_persona = platform_personas.get(channel, {})
|
||||
if platform_persona:
|
||||
visual_identity = platform_persona.get('visual_identity', {})
|
||||
if visual_identity:
|
||||
aesthetic = visual_identity.get('aesthetic_preferences', '')
|
||||
if aesthetic:
|
||||
enhanced_prompt += f", {aesthetic} aesthetic"
|
||||
|
||||
# Layer 3: Channel Optimization
|
||||
channel_enhancements = {
|
||||
'instagram': ', Instagram-optimized composition, vibrant colors, engaging visual',
|
||||
'linkedin': ', professional photography, clean composition, business-focused',
|
||||
'tiktok': ', dynamic composition, eye-catching, vertical format optimized',
|
||||
'facebook': ', social media optimized, engaging, shareable visual',
|
||||
'twitter': ', Twitter card optimized, clear focal point, readable at small size',
|
||||
'pinterest': ', Pinterest-optimized, vertical format, detailed and informative',
|
||||
}
|
||||
|
||||
if channel and channel.lower() in channel_enhancements:
|
||||
enhanced_prompt += channel_enhancements[channel.lower()]
|
||||
|
||||
# Layer 4: Asset Type Specific
|
||||
asset_type_enhancements = {
|
||||
'hero_image': ', hero image style, prominent product placement, professional photography',
|
||||
'product_photo': ', product photography, clean background, detailed product showcase',
|
||||
'lifestyle': ', lifestyle photography, natural setting, authentic scene',
|
||||
'social_post': ', social media post, engaging composition, optimized for engagement',
|
||||
}
|
||||
|
||||
if asset_type in asset_type_enhancements:
|
||||
enhanced_prompt += asset_type_enhancements[asset_type]
|
||||
|
||||
# Layer 5: Competitive Differentiation
|
||||
if competitor_analyses and len(competitor_analyses) > 0:
|
||||
# Extract unique positioning from competitor analysis
|
||||
enhanced_prompt += ", unique positioning, differentiated visual style"
|
||||
|
||||
# Layer 6: Quality Descriptors
|
||||
enhanced_prompt += ", professional photography, high quality, detailed, sharp focus, natural lighting"
|
||||
|
||||
# Layer 7: Marketing Context
|
||||
if product_context:
|
||||
marketing_goal = product_context.get('marketing_goal', '')
|
||||
if marketing_goal:
|
||||
enhanced_prompt += f", {marketing_goal} focused"
|
||||
|
||||
logger.info(f"[Campaign Prompt] Enhanced prompt for user {user_id}: {enhanced_prompt[:200]}...")
|
||||
return enhanced_prompt
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Campaign Prompt] Error building prompt: {str(e)}")
|
||||
# Return base prompt with minimal enhancement if error
|
||||
return f"{base_prompt}, professional photography, high quality"
|
||||
|
||||
def build_marketing_copy_prompt(
|
||||
self,
|
||||
base_request: str,
|
||||
user_id: str,
|
||||
channel: Optional[str] = None,
|
||||
content_type: str = "caption",
|
||||
product_context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Build enhanced marketing copy prompt with persona linguistic fingerprint.
|
||||
|
||||
Args:
|
||||
base_request: Base content request (e.g., "Write Instagram caption for product launch")
|
||||
user_id: User ID to fetch onboarding data
|
||||
channel: Target channel (instagram, linkedin, etc.)
|
||||
content_type: Type of content (caption, cta, email, ad_copy, etc.)
|
||||
product_context: Additional product information
|
||||
|
||||
Returns:
|
||||
Enhanced prompt with persona style, brand voice, and marketing context
|
||||
"""
|
||||
try:
|
||||
# Get onboarding data
|
||||
db = SessionLocal()
|
||||
try:
|
||||
onboarding_db = OnboardingDatabaseService(db)
|
||||
website_analysis = onboarding_db.get_website_analysis(user_id, db)
|
||||
persona_data = onboarding_db.get_persona_data(user_id, db)
|
||||
competitor_analyses = onboarding_db.get_competitor_analysis(user_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Build enhanced prompt
|
||||
enhanced_prompt = base_request
|
||||
|
||||
# Add persona linguistic fingerprint
|
||||
if persona_data:
|
||||
core_persona = persona_data.get('corePersona', {})
|
||||
platform_personas = persona_data.get('platformPersonas', {})
|
||||
|
||||
if core_persona:
|
||||
persona_name = core_persona.get('persona_name', '')
|
||||
linguistic_fingerprint = core_persona.get('linguistic_fingerprint', {})
|
||||
|
||||
if persona_name:
|
||||
enhanced_prompt += f"\n\nFollow {persona_name} persona style:"
|
||||
|
||||
if linguistic_fingerprint:
|
||||
sentence_metrics = linguistic_fingerprint.get('sentence_metrics', {})
|
||||
lexical_features = linguistic_fingerprint.get('lexical_features', {})
|
||||
|
||||
if sentence_metrics:
|
||||
avg_length = sentence_metrics.get('average_sentence_length_words', '')
|
||||
if avg_length:
|
||||
enhanced_prompt += f"\n- Average sentence length: {avg_length} words"
|
||||
|
||||
if lexical_features:
|
||||
go_to_words = lexical_features.get('go_to_words', [])
|
||||
avoid_words = lexical_features.get('avoid_words', [])
|
||||
vocabulary_level = lexical_features.get('vocabulary_level', '')
|
||||
|
||||
if go_to_words:
|
||||
enhanced_prompt += f"\n- Use these words: {', '.join(go_to_words[:5])}"
|
||||
if avoid_words:
|
||||
enhanced_prompt += f"\n- Avoid these words: {', '.join(avoid_words[:5])}"
|
||||
if vocabulary_level:
|
||||
enhanced_prompt += f"\n- Vocabulary level: {vocabulary_level}"
|
||||
|
||||
# Channel-specific persona adaptation
|
||||
if channel and platform_personas:
|
||||
platform_persona = platform_personas.get(channel, {})
|
||||
if platform_persona:
|
||||
content_format_rules = platform_persona.get('content_format_rules', {})
|
||||
engagement_patterns = platform_persona.get('engagement_patterns', {})
|
||||
|
||||
if content_format_rules:
|
||||
char_limit = content_format_rules.get('character_limit', '')
|
||||
hashtag_strategy = content_format_rules.get('hashtag_strategy', '')
|
||||
|
||||
if char_limit:
|
||||
enhanced_prompt += f"\n- Character limit: {char_limit}"
|
||||
if hashtag_strategy:
|
||||
enhanced_prompt += f"\n- Hashtag strategy: {hashtag_strategy}"
|
||||
|
||||
# Add brand voice
|
||||
if website_analysis:
|
||||
writing_style = website_analysis.get('writing_style', {})
|
||||
target_audience = website_analysis.get('target_audience', {})
|
||||
|
||||
tone = writing_style.get('tone', 'professional')
|
||||
voice = writing_style.get('voice', 'authoritative')
|
||||
enhanced_prompt += f"\n- Brand tone: {tone}, Brand voice: {voice}"
|
||||
|
||||
demographics = target_audience.get('demographics', [])
|
||||
expertise_level = target_audience.get('expertise_level', 'intermediate')
|
||||
if demographics:
|
||||
enhanced_prompt += f"\n- Target audience: {', '.join(demographics[:2])}, {expertise_level} level"
|
||||
|
||||
# Add competitive positioning
|
||||
if competitor_analyses and len(competitor_analyses) > 0:
|
||||
enhanced_prompt += "\n- Differentiate from competitors, highlight unique value propositions"
|
||||
|
||||
# Add marketing context
|
||||
if product_context:
|
||||
marketing_goal = product_context.get('marketing_goal', '')
|
||||
if marketing_goal:
|
||||
enhanced_prompt += f"\n- Marketing goal: {marketing_goal}"
|
||||
|
||||
logger.info(f"[Campaign Copy Prompt] Enhanced for user {user_id}: {enhanced_prompt[:200]}...")
|
||||
return enhanced_prompt
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Campaign Copy Prompt] Error building prompt: {str(e)}")
|
||||
return base_request
|
||||
|
||||
def optimize_marketing_prompt(
|
||||
self,
|
||||
prompt_type: str,
|
||||
base_prompt: str,
|
||||
user_id: str,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Main entry point for marketing prompt optimization.
|
||||
|
||||
Args:
|
||||
prompt_type: Type of prompt (image, copy, video_script, etc.)
|
||||
base_prompt: Base prompt to enhance
|
||||
user_id: User ID for personalization
|
||||
context: Additional context (channel, asset_type, product_context, etc.)
|
||||
|
||||
Returns:
|
||||
Optimized marketing prompt
|
||||
"""
|
||||
context = context or {}
|
||||
channel = context.get('channel')
|
||||
asset_type = context.get('asset_type', 'hero_image')
|
||||
content_type = context.get('content_type', 'caption')
|
||||
product_context = context.get('product_context')
|
||||
|
||||
if prompt_type == 'image':
|
||||
return self.build_marketing_image_prompt(
|
||||
base_prompt, user_id, channel, asset_type, product_context
|
||||
)
|
||||
elif prompt_type in ['copy', 'caption', 'cta', 'email', 'ad_copy']:
|
||||
return self.build_marketing_copy_prompt(
|
||||
base_prompt, user_id, channel, content_type, product_context
|
||||
)
|
||||
else:
|
||||
# Default: minimal enhancement
|
||||
return f"{base_prompt}, professional quality, marketing optimized"
|
||||
Reference in New Issue
Block a user