story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete

This commit is contained in:
ajaysi
2025-11-13 16:14:26 +05:30
parent 7191c7e7f0
commit 3b9356e2c8
124 changed files with 20055 additions and 1208 deletions

View File

@@ -0,0 +1,186 @@
"""
Introduction Generator - Generates varied blog introductions based on content and research.
Generates 3 different introduction options for the user to choose from.
"""
from typing import Dict, Any, List
from loguru import logger
from models.blog_models import BlogResearchResponse, BlogOutlineSection
class IntroductionGenerator:
"""Generates blog introductions using research and content data."""
def __init__(self):
"""Initialize the introduction generator."""
pass
def build_introduction_prompt(
self,
blog_title: str,
research: BlogResearchResponse,
outline: List[BlogOutlineSection],
sections_content: Dict[str, str],
primary_keywords: List[str],
search_intent: str
) -> str:
"""Build a prompt for generating blog introductions."""
# Extract key research insights
keyword_analysis = research.keyword_analysis or {}
content_angles = research.suggested_angles or []
# Get a summary of the first few sections for context
section_summaries = []
for i, section in enumerate(outline[:3], 1):
section_id = section.id
content = sections_content.get(section_id, '')
if content:
# Take first 200 chars as summary
summary = content[:200] + '...' if len(content) > 200 else content
section_summaries.append(f"{i}. {section.heading}: {summary}")
sections_text = '\n'.join(section_summaries) if section_summaries else "Content sections are being generated."
primary_kw_text = ', '.join(primary_keywords) if primary_keywords else "the topic"
content_angle_text = ', '.join(content_angles[:3]) if content_angles else "General insights"
return f"""Generate exactly 3 varied blog introductions for the following blog post.
BLOG TITLE: {blog_title}
PRIMARY KEYWORDS: {primary_kw_text}
SEARCH INTENT: {search_intent}
CONTENT ANGLES: {content_angle_text}
BLOG CONTENT SUMMARY:
{sections_text}
REQUIREMENTS FOR EACH INTRODUCTION:
- 80-120 words in length
- Hook the reader immediately with a compelling opening
- Clearly state the value proposition and what readers will learn
- Include the primary keyword naturally within the first 2 sentences
- Each introduction should have a different angle/approach:
1. First: Problem-focused (highlight the challenge readers face)
2. Second: Benefit-focused (emphasize the value and outcomes)
3. Third: Story/statistic-focused (use a compelling fact or narrative hook)
- Maintain a professional yet engaging tone
- Avoid generic phrases - be specific and benefit-driven
Return ONLY a JSON array of exactly 3 introductions:
[
"First introduction (80-120 words, problem-focused)",
"Second introduction (80-120 words, benefit-focused)",
"Third introduction (80-120 words, story/statistic-focused)"
]"""
def get_introduction_schema(self) -> Dict[str, Any]:
"""Get the JSON schema for introduction generation."""
return {
"type": "array",
"items": {
"type": "string",
"minLength": 80,
"maxLength": 150
},
"minItems": 3,
"maxItems": 3
}
async def generate_introductions(
self,
blog_title: str,
research: BlogResearchResponse,
outline: List[BlogOutlineSection],
sections_content: Dict[str, str],
primary_keywords: List[str],
search_intent: str,
user_id: str
) -> List[str]:
"""Generate 3 varied blog introductions.
Args:
blog_title: The blog post title
research: Research data with keywords and insights
outline: Blog outline sections
sections_content: Dictionary mapping section IDs to their content
primary_keywords: Primary keywords for the blog
search_intent: Search intent (informational, commercial, etc.)
user_id: User ID for API calls
Returns:
List of 3 introduction options
"""
from services.llm_providers.main_text_generation import llm_text_gen
if not user_id:
raise ValueError("user_id is required for introduction generation")
# Build prompt
prompt = self.build_introduction_prompt(
blog_title=blog_title,
research=research,
outline=outline,
sections_content=sections_content,
primary_keywords=primary_keywords,
search_intent=search_intent
)
# Get schema
schema = self.get_introduction_schema()
logger.info(f"Generating blog introductions for user {user_id}")
try:
# Generate introductions using structured JSON response
result = llm_text_gen(
prompt=prompt,
json_struct=schema,
system_prompt="You are an expert content writer specializing in creating compelling blog introductions that hook readers and clearly communicate value.",
user_id=user_id
)
# Handle response - could be array directly or wrapped in dict
if isinstance(result, list):
introductions = result
elif isinstance(result, dict):
# Try common keys
introductions = result.get('introductions', result.get('options', result.get('intros', [])))
if not introductions and isinstance(result.get('response'), list):
introductions = result['response']
else:
logger.warning(f"Unexpected introduction generation result type: {type(result)}")
introductions = []
# Validate and clean introductions
cleaned_introductions = []
for intro in introductions:
if isinstance(intro, str) and len(intro.strip()) >= 50: # Minimum reasonable length
cleaned = intro.strip()
# Ensure it's within reasonable bounds
if len(cleaned) <= 200: # Allow slight overflow for quality
cleaned_introductions.append(cleaned)
# Ensure we have exactly 3 introductions
if len(cleaned_introductions) < 3:
logger.warning(f"Generated only {len(cleaned_introductions)} introductions, expected 3")
# Pad with placeholder if needed
while len(cleaned_introductions) < 3:
cleaned_introductions.append(f"{blog_title} - A comprehensive guide covering essential insights and practical strategies.")
# Return exactly 3 introductions
return cleaned_introductions[:3]
except Exception as e:
logger.error(f"Failed to generate introductions: {e}")
# Fallback: generate simple introductions
fallback_introductions = [
f"In this comprehensive guide, we'll explore {primary_keywords[0] if primary_keywords else 'essential insights'} and provide actionable strategies.",
f"Discover everything you need to know about {primary_keywords[0] if primary_keywords else 'this topic'} and how it can transform your approach.",
f"Whether you're new to {primary_keywords[0] if primary_keywords else 'this topic'} or looking to deepen your understanding, this guide has you covered."
]
return fallback_introductions

View File

@@ -5,7 +5,6 @@ Constructs comprehensive prompts with research data, keywords, and strategic req
"""
from typing import Dict, Any, List
from loguru import logger
class PromptBuilder:
@@ -23,7 +22,18 @@ class PromptBuilder:
# Use the filtered research data (already cleaned by ResearchDataFilter)
research = request.research
return f"""Create a comprehensive blog outline for: {', '.join(primary_keywords)}
primary_kw_text = ', '.join(primary_keywords) if primary_keywords else (request.topic or ', '.join(getattr(request.research, 'original_keywords', []) or ['the target topic']))
secondary_kw_text = ', '.join(secondary_keywords) if secondary_keywords else "None provided"
long_tail_text = ', '.join(research.keyword_analysis.get('long_tail', [])) if research and research.keyword_analysis else "None discovered"
semantic_text = ', '.join(research.keyword_analysis.get('semantic_keywords', [])) if research and research.keyword_analysis else "None discovered"
trending_text = ', '.join(research.keyword_analysis.get('trending_terms', [])) if research and research.keyword_analysis else "None discovered"
content_gap_text = ', '.join(research.keyword_analysis.get('content_gaps', [])) if research and research.keyword_analysis else "None identified"
content_angle_text = ', '.join(content_angles) if content_angles else "No explicit angles provided; infer compelling angles from research insights."
competitor_text = ', '.join(research.competitor_analysis.get('top_competitors', [])) if research and research.competitor_analysis else "Not available"
opportunity_text = ', '.join(research.competitor_analysis.get('opportunities', [])) if research and research.competitor_analysis else "Not available"
advantages_text = ', '.join(research.competitor_analysis.get('competitive_advantages', [])) if research and research.competitor_analysis else "Not available"
return f"""Create a comprehensive blog outline for: {primary_kw_text}
CONTEXT:
Search Intent: {search_intent}
@@ -32,19 +42,19 @@ Industry: {getattr(request.persona, 'industry', 'General') if request.persona el
Audience: {getattr(request.persona, 'target_audience', 'General') if request.persona else 'General'}
KEYWORDS:
Primary: {', '.join(primary_keywords)}
Secondary: {', '.join(secondary_keywords)}
Long-tail: {', '.join(research.keyword_analysis.get('long_tail', []))}
Semantic: {', '.join(research.keyword_analysis.get('semantic_keywords', []))}
Trending: {', '.join(research.keyword_analysis.get('trending_terms', []))}
Content Gaps: {', '.join(research.keyword_analysis.get('content_gaps', []))}
Primary: {primary_kw_text}
Secondary: {secondary_kw_text}
Long-tail: {long_tail_text}
Semantic: {semantic_text}
Trending: {trending_text}
Content Gaps: {content_gap_text}
CONTENT ANGLES: {', '.join(content_angles)}
CONTENT ANGLES / STORYLINES: {content_angle_text}
COMPETITIVE INTELLIGENCE:
Top Competitors: {', '.join(research.competitor_analysis.get('top_competitors', []))}
Market Opportunities: {', '.join(research.competitor_analysis.get('opportunities', []))}
Competitive Advantages: {', '.join(research.competitor_analysis.get('competitive_advantages', []))}
Top Competitors: {competitor_text}
Market Opportunities: {opportunity_text}
Competitive Advantages: {advantages_text}
RESEARCH SOURCES: {len(sources)} authoritative sources available
@@ -52,6 +62,7 @@ RESEARCH SOURCES: {len(sources)} authoritative sources available
STRATEGIC REQUIREMENTS:
- Create SEO-optimized headings with natural keyword integration
- Surface the strongest research-backed angles within the outline
- Build logical narrative flow from problem to solution
- Include data-driven insights from research sources
- Address content gaps and market opportunities
@@ -59,23 +70,34 @@ STRATEGIC REQUIREMENTS:
- Ensure engaging, actionable content throughout
Return JSON format:
{{
"outline": [
{{
"heading": "Section heading with primary keyword",
"subheadings": ["Subheading 1", "Subheading 2", "Subheading 3"],
"key_points": ["Key point 1", "Key point 2", "Key point 3"],
{
"title_options": [
"Title option 1",
"Title option 2",
"Title option 3"
],
"outline": [
{
"heading": "Section heading with primary keyword",
"subheadings": ["Subheading 1", "Subheading 2", "Subheading 3"],
"key_points": ["Key point 1", "Key point 2", "Key point 3"],
"target_words": 300,
"keywords": ["primary keyword", "secondary keyword"]
}}
]
}}"""
"keywords": ["primary keyword", "secondary keyword"]
}
]
}"""
def get_outline_schema(self) -> Dict[str, Any]:
"""Get the structured JSON schema for outline generation."""
return {
"type": "object",
"properties": {
"title_options": {
"type": "array",
"items": {
"type": "string"
}
},
"outline": {
"type": "array",
"items": {
@@ -100,6 +122,6 @@ Return JSON format:
}
}
},
"required": ["outline"],
"propertyOrdering": ["outline"]
"required": ["title_options", "outline"],
"propertyOrdering": ["title_options", "outline"]
}

View File

@@ -0,0 +1,198 @@
"""
SEO Title Generator - Specialized service for generating SEO-optimized blog titles.
Generates 5 premium SEO-optimized titles using research data and outline context.
"""
from typing import Dict, Any, List
from loguru import logger
from models.blog_models import BlogResearchResponse, BlogOutlineSection
class SEOTitleGenerator:
"""Generates SEO-optimized blog titles using research and outline data."""
def __init__(self):
"""Initialize the SEO title generator."""
pass
def build_title_prompt(
self,
research: BlogResearchResponse,
outline: List[BlogOutlineSection],
primary_keywords: List[str],
secondary_keywords: List[str],
content_angles: List[str],
search_intent: str,
word_count: int = 1500
) -> str:
"""Build a specialized prompt for SEO title generation."""
# Extract key research insights
keyword_analysis = research.keyword_analysis or {}
competitor_analysis = research.competitor_analysis or {}
primary_kw_text = ', '.join(primary_keywords) if primary_keywords else "the target topic"
secondary_kw_text = ', '.join(secondary_keywords) if secondary_keywords else "None provided"
long_tail_text = ', '.join(keyword_analysis.get('long_tail', [])) if keyword_analysis else "None discovered"
semantic_text = ', '.join(keyword_analysis.get('semantic_keywords', [])) if keyword_analysis else "None discovered"
trending_text = ', '.join(keyword_analysis.get('trending_terms', [])) if keyword_analysis else "None discovered"
content_gap_text = ', '.join(keyword_analysis.get('content_gaps', [])) if keyword_analysis else "None identified"
content_angle_text = ', '.join(content_angles) if content_angles else "No explicit angles provided"
# Extract outline structure summary
outline_summary = []
for i, section in enumerate(outline[:5], 1): # Limit to first 5 sections for context
outline_summary.append(f"{i}. {section.heading}")
if section.subheadings:
outline_summary.append(f" Subtopics: {', '.join(section.subheadings[:3])}")
outline_text = '\n'.join(outline_summary) if outline_summary else "No outline available"
return f"""Generate exactly 5 SEO-optimized blog titles for: {primary_kw_text}
RESEARCH CONTEXT:
Primary Keywords: {primary_kw_text}
Secondary Keywords: {secondary_kw_text}
Long-tail Keywords: {long_tail_text}
Semantic Keywords: {semantic_text}
Trending Terms: {trending_text}
Content Gaps: {content_gap_text}
Search Intent: {search_intent}
Content Angles: {content_angle_text}
OUTLINE STRUCTURE:
{outline_text}
COMPETITIVE INTELLIGENCE:
Top Competitors: {', '.join(competitor_analysis.get('top_competitors', [])) if competitor_analysis else 'Not available'}
Market Opportunities: {', '.join(competitor_analysis.get('opportunities', [])) if competitor_analysis else 'Not available'}
SEO REQUIREMENTS:
- Each title must be 50-65 characters (optimal for search engine display)
- Include the primary keyword within the first 55 characters
- Highlight a unique value proposition from the research angles
- Use power words that drive clicks (e.g., "Ultimate", "Complete", "Essential", "Proven")
- Avoid generic phrasing - be specific and benefit-focused
- Target the search intent: {search_intent}
- Ensure titles are compelling and click-worthy
Return ONLY a JSON array of exactly 5 titles:
[
"Title 1 (50-65 chars)",
"Title 2 (50-65 chars)",
"Title 3 (50-65 chars)",
"Title 4 (50-65 chars)",
"Title 5 (50-65 chars)"
]"""
def get_title_schema(self) -> Dict[str, Any]:
"""Get the JSON schema for title generation."""
return {
"type": "array",
"items": {
"type": "string",
"minLength": 50,
"maxLength": 65
},
"minItems": 5,
"maxItems": 5
}
async def generate_seo_titles(
self,
research: BlogResearchResponse,
outline: List[BlogOutlineSection],
primary_keywords: List[str],
secondary_keywords: List[str],
content_angles: List[str],
search_intent: str,
word_count: int,
user_id: str
) -> List[str]:
"""Generate SEO-optimized titles using research and outline data.
Args:
research: Research data with keywords and insights
outline: Blog outline sections
primary_keywords: Primary keywords for the blog
secondary_keywords: Secondary keywords
content_angles: Content angles from research
search_intent: Search intent (informational, commercial, etc.)
word_count: Target word count
user_id: User ID for API calls
Returns:
List of 5 SEO-optimized titles
"""
from services.llm_providers.main_text_generation import llm_text_gen
if not user_id:
raise ValueError("user_id is required for title generation")
# Build specialized prompt
prompt = self.build_title_prompt(
research=research,
outline=outline,
primary_keywords=primary_keywords,
secondary_keywords=secondary_keywords,
content_angles=content_angles,
search_intent=search_intent,
word_count=word_count
)
# Get schema
schema = self.get_title_schema()
logger.info(f"Generating SEO-optimized titles for user {user_id}")
try:
# Generate titles using structured JSON response
result = llm_text_gen(
prompt=prompt,
json_struct=schema,
system_prompt="You are an expert SEO content strategist specializing in creating compelling, search-optimized blog titles.",
user_id=user_id
)
# Handle response - could be array directly or wrapped in dict
if isinstance(result, list):
titles = result
elif isinstance(result, dict):
# Try common keys
titles = result.get('titles', result.get('title_options', result.get('options', [])))
if not titles and isinstance(result.get('response'), list):
titles = result['response']
else:
logger.warning(f"Unexpected title generation result type: {type(result)}")
titles = []
# Validate and clean titles
cleaned_titles = []
for title in titles:
if isinstance(title, str) and len(title.strip()) >= 30: # Minimum reasonable length
cleaned = title.strip()
# Ensure it's within reasonable bounds (allow slight overflow for quality)
if len(cleaned) <= 70: # Allow slight overflow for quality
cleaned_titles.append(cleaned)
# Ensure we have exactly 5 titles
if len(cleaned_titles) < 5:
logger.warning(f"Generated only {len(cleaned_titles)} titles, expected 5")
# Pad with placeholder if needed (shouldn't happen with proper schema)
while len(cleaned_titles) < 5:
cleaned_titles.append(f"{primary_keywords[0] if primary_keywords else 'Blog'} - Comprehensive Guide")
# Return exactly 5 titles
return cleaned_titles[:5]
except Exception as e:
logger.error(f"Failed to generate SEO titles: {e}")
# Fallback: generate simple titles from keywords
fallback_titles = []
primary = primary_keywords[0] if primary_keywords else "Blog Post"
for i in range(5):
fallback_titles.append(f"{primary}: Complete Guide {i+1}")
return fallback_titles

View File

@@ -74,7 +74,9 @@ class ResearchService:
if cached_result:
logger.info(f"Returning cached research result for keywords: {request.keywords}")
blog_writer_logger.log_operation_end("research", 0, success=True, cache_hit=True)
return BlogResearchResponse(**cached_result)
# Normalize cached data to fix None values in confidence_scores
normalized_result = self._normalize_cached_research_data(cached_result)
return BlogResearchResponse(**normalized_result)
# User ID validation (validation logic is now in Google Grounding provider)
if not user_id:
@@ -421,7 +423,9 @@ class ResearchService:
if cached_result:
await task_manager.update_progress(task_id, "✅ Found cached research results! Returning instantly...")
logger.info(f"Returning cached research result for keywords: {request.keywords}")
return BlogResearchResponse(**cached_result)
# Normalize cached data to fix None values in confidence_scores
normalized_result = self._normalize_cached_research_data(cached_result)
return BlogResearchResponse(**normalized_result)
# User ID validation
if not user_id:
@@ -759,6 +763,49 @@ class ResearchService:
return sources
def _normalize_cached_research_data(self, cached_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize cached research data to fix None values in confidence_scores.
Ensures all GroundingSupport objects have confidence_scores as a list.
"""
if not isinstance(cached_data, dict):
return cached_data
normalized = cached_data.copy()
# Normalize grounding_metadata if present
if "grounding_metadata" in normalized and normalized["grounding_metadata"]:
grounding_metadata = normalized["grounding_metadata"].copy() if isinstance(normalized["grounding_metadata"], dict) else {}
# Normalize grounding_supports
if "grounding_supports" in grounding_metadata and isinstance(grounding_metadata["grounding_supports"], list):
normalized_supports = []
for support in grounding_metadata["grounding_supports"]:
if isinstance(support, dict):
normalized_support = support.copy()
# Fix confidence_scores: ensure it's a list, not None
if normalized_support.get("confidence_scores") is None:
normalized_support["confidence_scores"] = []
elif not isinstance(normalized_support.get("confidence_scores"), list):
# If it's not a list, try to convert or default to empty list
normalized_support["confidence_scores"] = []
# Fix grounding_chunk_indices: ensure it's a list, not None
if normalized_support.get("grounding_chunk_indices") is None:
normalized_support["grounding_chunk_indices"] = []
elif not isinstance(normalized_support.get("grounding_chunk_indices"), list):
normalized_support["grounding_chunk_indices"] = []
# Ensure segment_text is a string
if normalized_support.get("segment_text") is None:
normalized_support["segment_text"] = ""
normalized_supports.append(normalized_support)
else:
normalized_supports.append(support)
grounding_metadata["grounding_supports"] = normalized_supports
normalized["grounding_metadata"] = grounding_metadata
return normalized
def _extract_grounding_metadata(self, gemini_result: Dict[str, Any]) -> GroundingMetadata:
"""Extract detailed grounding metadata from Gemini result."""
grounding_chunks = []

View File

@@ -25,7 +25,11 @@ class WixAuthService:
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE',
'scope': (
'BLOG.CREATE-DRAFT,BLOG.PUBLISH-POST,BLOG.READ-CATEGORY,'
'BLOG.CREATE-CATEGORY,BLOG.READ-TAG,BLOG.CREATE-TAG,'
'MEDIA.SITE_MEDIA_FILES_IMPORT'
),
'code_challenge': code_challenge,
'code_challenge_method': 'S256'
}

View File

@@ -0,0 +1,132 @@
"""
Authentication utilities for Wix API requests.
Supports both OAuth Bearer tokens and API keys for Wix Headless apps.
"""
import os
from typing import Dict, Optional
from loguru import logger
def get_wix_headers(
access_token: str,
client_id: Optional[str] = None,
extra: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""
Build headers for Wix API requests with automatic token type detection.
Supports:
- OAuth Bearer tokens (JWT format: xxx.yyy.zzz)
- Wix API keys (for Headless apps)
Args:
access_token: OAuth token OR API key
client_id: Optional Wix client ID
extra: Additional headers to include
Returns:
Headers dict with proper Authorization format
"""
headers: Dict[str, str] = {
'Content-Type': 'application/json',
}
if access_token:
# Ensure access_token is a string (defensive check)
if not isinstance(access_token, str):
from services.integrations.wix.utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token:
# Detect token type
# API keys are typically longer and don't have JWT structure (xxx.yyy.zzz)
# JWT tokens have exactly 2 dots separating 3 parts
# Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# These should use "Bearer" prefix even though they have more than 2 dots
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
else:
# Count dots - JWT has exactly 2 dots
dot_count = token.count('.')
if dot_count == 2 and len(token) < 500:
# Likely OAuth JWT token - use Bearer prefix
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using OAuth Bearer token (JWT format detected)")
else:
# Likely API key - use directly without Bearer prefix
headers['Authorization'] = token
logger.debug(f"Using API key for authorization (non-JWT format detected)")
if client_id:
headers['wix-client-id'] = client_id
if extra:
headers.update(extra)
return headers
def get_wix_api_key() -> Optional[str]:
"""
Get Wix API key from environment.
For Wix Headless apps, API keys provide admin-level access.
Returns:
API key if set, None otherwise
"""
api_key = os.getenv('WIX_API_KEY')
if api_key:
logger.warning(f"✅ Wix API key found in environment ({len(api_key)} chars)")
else:
logger.warning("❌ No Wix API key in environment")
return api_key
def should_use_api_key(access_token: Optional[str] = None) -> bool:
"""
Determine if we should use API key instead of OAuth token.
Use API key if:
- No OAuth token provided
- OAuth token is getting 403 errors
- API key is available in environment
Args:
access_token: Optional OAuth token
Returns:
True if should use API key, False otherwise
"""
# If no access token, check for API key
if not access_token or not access_token.strip():
return get_wix_api_key() is not None
# If access token looks like API key already, use it
# Ensure access_token is a string (defensive check)
if not isinstance(access_token, str):
from services.integrations.wix.utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token.count('.') != 2 or len(token) > 500:
return True
return False

View File

@@ -10,9 +10,39 @@ class WixBlogService:
def headers(self, access_token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
h: Dict[str, str] = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
# Support both OAuth tokens and API keys
# API keys don't use 'Bearer' prefix
# Ensure access_token is a string (defensive check)
if access_token:
# Normalize token to string if needed
if not isinstance(access_token, str):
from .utils import normalize_token_string
normalized = normalize_token_string(access_token)
if normalized:
access_token = normalized
else:
access_token = str(access_token)
token = access_token.strip()
if token:
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# These should use "Bearer" prefix even though they have more than 2 dots
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
h['Authorization'] = f'Bearer {token}'
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
elif '.' not in token or len(token) > 500:
# Likely an API key - use directly without Bearer prefix
h['Authorization'] = token
logger.debug("Using API key for authorization")
else:
# Standard JWT OAuth token (xxx.yyy.zzz format) - use Bearer prefix
h['Authorization'] = f'Bearer {token}'
logger.debug("Using OAuth Bearer token for authorization")
if self.client_id:
h['wix-client-id'] = self.client_id
if extra:
@@ -20,41 +50,38 @@ class WixBlogService:
return h
def create_draft_post(self, access_token: str, payload: Dict[str, Any], extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
# Log the exact payload being sent for debugging
"""Create draft post with consolidated logging"""
from .logger import wix_logger
import json
logger.warning(f"📤 Sending to Wix Blog API:")
logger.warning(f" Endpoint: {self.base_url}/blog/v3/draft-posts")
logger.warning(f" Payload top-level keys: {list(payload.keys())}")
# Build payload summary for logging
payload_summary = {}
if 'draftPost' in payload:
dp = payload['draftPost']
logger.warning(f" draftPost keys: {list(dp.keys())}")
if 'richContent' in dp:
rc = dp['richContent']
logger.warning(f" richContent keys: {list(rc.keys()) if isinstance(rc, dict) else 'N/A'}")
if isinstance(rc, dict) and 'nodes' in rc:
nodes = rc['nodes']
logger.warning(f" richContent.nodes count: {len(nodes) if isinstance(nodes, list) else 'N/A'}")
# Inspect first LIST_ITEM node if any
for i, node in enumerate(nodes[:10]):
if isinstance(node, dict) and node.get('type') == 'LIST_ITEM':
logger.warning(f" Found LIST_ITEM at index {i}:")
logger.warning(f" Keys: {list(node.keys())}")
logger.warning(f" Has listItemData: {'listItemData' in node}")
if 'listItemData' in node:
logger.warning(f" listItemData type: {type(node['listItemData'])}, value: {node['listItemData']}")
if 'nodes' in node:
nested = node['nodes']
logger.warning(f" Nested nodes count: {len(nested) if isinstance(nested, list) else 'N/A'}")
for j, n_node in enumerate(nested[:3]):
if isinstance(n_node, dict):
logger.warning(f" Nested node {j}: type={n_node.get('type')}, keys={list(n_node.keys())}")
if n_node.get('type') == 'PARAGRAPH' and 'paragraphData' in n_node:
logger.warning(f" paragraphData type: {type(n_node['paragraphData'])}, value: {n_node['paragraphData']}")
break # Only inspect first LIST_ITEM
payload_summary['draftPost'] = {
'title': dp.get('title'),
'richContent': {'nodes': len(dp.get('richContent', {}).get('nodes', []))} if 'richContent' in dp else None,
'seoData': 'seoData' in dp
}
logger.warning(f" Full Payload JSON (first 8000 chars):\n{json.dumps(payload, indent=2, ensure_ascii=False)[:8000]}...")
request_headers = self.headers(access_token, extra_headers)
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=request_headers, json=payload)
# Consolidated error logging
error_body = None
if response.status_code >= 400:
try:
error_body = response.json()
except:
error_body = {'message': response.text[:200]}
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", response.status_code, payload_summary, error_body)
if response.status_code >= 400:
# Only show detailed error info for debugging
if response.status_code == 500:
logger.debug(f" Full error: {json.dumps(error_body, indent=2) if isinstance(error_body, dict) else error_body}")
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=self.headers(access_token, extra_headers), json=payload)
response.raise_for_status()
return response.json()

View File

@@ -14,6 +14,8 @@ from services.integrations.wix.blog import WixBlogService
from services.integrations.wix.content import convert_content_to_ricos
from services.integrations.wix.ricos_converter import convert_via_wix_api
from services.integrations.wix.seo import build_seo_data
from services.integrations.wix.logger import wix_logger
from services.integrations.wix.utils import normalize_token_string
def validate_ricos_content(ricos_content: Dict[str, Any]) -> Dict[str, Any]:
@@ -220,10 +222,96 @@ def create_blog_post(
if not member_id:
raise ValueError("memberId is required for third-party apps creating blog posts")
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
# Ensure access_token is a string (handle cases where it might be int, dict, or other type)
# Use normalize_token_string to handle various token formats (dict with accessToken.value, etc.)
normalized_token = normalize_token_string(access_token)
if not normalized_token:
raise ValueError("access_token is required and must be a valid string or token object")
access_token = normalized_token.strip()
if not access_token:
raise ValueError("access_token cannot be empty")
# BACK TO BASICS MODE: Try simplest possible structure FIRST
# Since posting worked before Ricos/SEO, let's test with absolute minimum
BACK_TO_BASICS_MODE = True # Set to True to test with simplest structure
wix_logger.reset()
wix_logger.log_operation_start("Blog Post Creation", title=title[:50] if title else None, member_id=member_id[:20] if member_id else None)
if BACK_TO_BASICS_MODE:
logger.info("🔧 Wix: BACK TO BASICS MODE - Testing minimal structure")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Create absolute minimal Ricos structure
minimal_ricos = {
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {
'text': (content[:500] if content else "This is a post from ALwrity.").strip(),
'decorations': []
}
}],
'paragraphData': {}
}]
}
# Extract wix-site-id from token if possible
extra_headers = {}
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
except Exception:
pass
# Build minimal payload
minimal_blog_data = {
'draftPost': {
'title': str(title).strip() if title else "Untitled",
'memberId': str(member_id).strip(),
'richContent': minimal_ricos
},
'publish': False,
'fieldsets': ['URL']
}
try:
from .blog import WixBlogService
blog_service_test = WixBlogService('https://www.wixapis.com', None)
result = blog_service_test.create_draft_post(access_token, minimal_blog_data, extra_headers if extra_headers else None)
logger.success("✅✅✅ Wix: BACK TO BASICS SUCCEEDED! Issue is with Ricos/SEO structure")
wix_logger.log_operation_result("Back to Basics Test", True, result)
return result
except Exception as e:
logger.error(f"❌ Wix: BACK TO BASICS FAILED - {str(e)[:100]}")
logger.error(" ⚠️ Issue is NOT with Ricos/SEO - likely permissions/token")
wix_logger.add_error(f"Back to Basics: {str(e)[:100]}")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Headers for blog post creation (use user's OAuth token)
headers = get_wix_headers(access_token)
# Build valid Ricos rich content
# Ensure content is not empty
@@ -231,20 +319,87 @@ def create_blog_post(
content = "This is a post from ALwrity."
logger.warning("⚠️ Content was empty, using default text")
# Try Wix API first (more reliable), fall back to custom parser
ricos_content = None
# Quick token/permission check (only log if issues found)
has_blog_scope = None
meta_site_id = None
try:
logger.warning("🔄 Attempting to convert markdown to Ricos via Wix API...")
ricos_content = convert_via_wix_api(content, access_token, base_url)
logger.warning(f"✅ Wix API conversion successful. Ricos document has {len(ricos_content.get('nodes', []))} nodes")
except Exception as e:
logger.warning(f"⚠️ Wix Ricos API conversion failed: {e}. Falling back to custom parser...")
# Fall back to custom parser
ricos_content = convert_content_to_ricos(content, None)
logger.warning(f"✅ Custom parser conversion complete. Ricos document has {len(ricos_content.get('nodes', []))} nodes")
from .utils import decode_wix_token
import json
token_data = decode_wix_token(access_token)
if 'scope' in token_data:
scopes = token_data.get('scope')
if isinstance(scopes, str):
scope_list = scopes.split(',') if ',' in scopes else [scopes]
has_blog_scope = any('BLOG' in s.upper() for s in scope_list)
if not has_blog_scope:
logger.error("❌ Wix: Token missing BLOG scopes - verify OAuth app permissions")
if 'data' in token_data:
data = token_data.get('data')
if isinstance(data, str):
try:
data = json.loads(data)
except:
pass
if isinstance(data, dict) and 'instance' in data:
instance = data.get('instance', {})
meta_site_id = instance.get('metaSiteId')
except Exception:
pass
# Validate Ricos content
ricos_content = validate_ricos_content(ricos_content)
# Quick permission test (only log failures)
try:
test_headers = get_wix_headers(access_token)
import requests
test_response = requests.get(f"{base_url}/blog/v3/categories", headers=test_headers, timeout=5)
if test_response.status_code == 403:
logger.error("❌ Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
elif test_response.status_code == 401:
logger.error("❌ Wix: Unauthorized - token may be expired")
except Exception:
pass
# Safely get token length (access_token is already validated as string above)
token_length = len(access_token) if access_token else 0
wix_logger.log_token_info(token_length, has_blog_scope, meta_site_id)
# Convert markdown to Ricos
ricos_content = convert_content_to_ricos(content, None)
nodes_count = len(ricos_content.get('nodes', []))
wix_logger.log_ricos_conversion(nodes_count)
# Validate Ricos content structure
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# The example in docs shows: { nodes: [...] } - no type, id, metadata, or documentStyle
if not isinstance(ricos_content, dict):
logger.error(f"❌ richContent is not a dict: {type(ricos_content)}")
raise ValueError("richContent must be a dictionary object")
if 'nodes' not in ricos_content or not isinstance(ricos_content['nodes'], list):
logger.error(f"❌ richContent.nodes is missing or not a list: {ricos_content.get('nodes', 'MISSING')}")
raise ValueError("richContent must contain a 'nodes' array")
# Remove type and id fields (not expected by Blog API)
# NOTE: metadata is optional - Wix UPDATE endpoint example shows it, but CREATE example doesn't
# We'll keep it minimal (nodes only) for CREATE to match the recipe example
fields_to_remove = ['type', 'id']
for field in fields_to_remove:
if field in ricos_content:
logger.debug(f"Removing '{field}' field from richContent (Blog API doesn't expect this)")
del ricos_content[field]
# Remove metadata and documentStyle - Blog API CREATE endpoint example shows only 'nodes'
# (UPDATE endpoint shows metadata, but we're using CREATE)
if 'metadata' in ricos_content:
logger.debug("Removing 'metadata' from richContent (CREATE endpoint expects only 'nodes')")
del ricos_content['metadata']
if 'documentStyle' in ricos_content:
logger.debug("Removing 'documentStyle' from richContent (CREATE endpoint expects only 'nodes')")
del ricos_content['documentStyle']
# Ensure we only have 'nodes' in richContent for CREATE endpoint
ricos_content = {'nodes': ricos_content['nodes']}
logger.debug(f"✅ richContent structure validated: {len(ricos_content['nodes'])} nodes, keys: {list(ricos_content.keys())}")
# Minimal payload per Wix docs: title, memberId, and richContent
# CRITICAL: Only include fields that have valid values (no None, no empty strings for required fields)
@@ -252,7 +407,7 @@ def create_blog_post(
'draftPost': {
'title': str(title).strip() if title else "Untitled",
'memberId': str(member_id).strip(), # Required for third-party apps (validated above)
'richContent': ricos_content, # Must be a valid Ricos document object
'richContent': ricos_content, # Must be a valid Ricos object with ONLY 'nodes'
},
'publish': bool(publish),
'fieldsets': ['URL'] # Simplified fieldsets
@@ -340,76 +495,34 @@ def create_blog_post(
logger.warning("All tag IDs were invalid, not including tagIds in payload")
# Build SEO data from metadata if provided
# NOTE: seoData is optional - if it causes issues, we can create post without it
seo_data = None
if seo_metadata:
logger.warning(f"📊 Building SEO data from metadata. Keys: {list(seo_metadata.keys())}")
seo_data = build_seo_data(seo_metadata, title)
if seo_data:
# Log detailed SEO structure
logger.warning(f"📋 SEO data built: {len(seo_data.get('tags', []))} tags, {len(seo_data.get('settings', {}).get('keywords', []))} keywords")
# Log each SEO tag for debugging (key ones only to avoid too much output)
if seo_data.get('tags'):
for idx, tag in enumerate(seo_data['tags'][:3]): # First 3 tags only
tag_type = tag.get('type')
if tag_type == 'title':
logger.warning(f" SEO tag {idx+1}: type={tag_type}, children={str(tag.get('children', ''))[:50]}...")
else:
props = tag.get('props', {})
content_preview = str(props.get('content', props.get('href', props.get('name', ''))))[:50]
logger.warning(f" SEO tag {idx+1}: type={tag_type}, props={list(props.keys())}, content={content_preview}...")
if len(seo_data['tags']) > 3:
logger.warning(f" ... and {len(seo_data['tags']) - 3} more SEO tags")
blog_data['draftPost']['seoData'] = seo_data
logger.warning(f"✅ Added seoData to blog post with {len(seo_data.get('tags', []))} tags")
else:
logger.warning("⚠️ SEO data was empty after building - check build_seo_data function")
try:
seo_data = build_seo_data(seo_metadata, title)
if seo_data:
tags_count = len(seo_data.get('tags', []))
keywords_count = len(seo_data.get('settings', {}).get('keywords', []))
wix_logger.log_seo_data(tags_count, keywords_count)
blog_data['draftPost']['seoData'] = seo_data
except Exception as e:
logger.warning(f"⚠️ Wix: SEO data build failed - {str(e)[:50]}")
wix_logger.add_warning(f"SEO build: {str(e)[:50]}")
# Add SEO slug if provided (separate field from seoData)
# Add SEO slug if provided
if seo_metadata.get('url_slug'):
blog_data['draftPost']['seoSlug'] = str(seo_metadata.get('url_slug')).strip()
logger.warning(f"✅ Added SEO slug: {blog_data['draftPost']['seoSlug']}")
else:
logger.warning("⚠️ No SEO metadata provided to create_blog_post")
# Log the payload structure for debugging (without sensitive data)
logger.warning(f"📝 Creating blog post with title: '{title}'")
logger.warning(f"📋 Draft post fields: {list(blog_data['draftPost'].keys())}")
# Detailed SEO logging
if 'seoData' in blog_data['draftPost']:
seo_data_debug = blog_data['draftPost']['seoData']
logger.warning(f"📊 SEO data in payload: {len(seo_data_debug.get('tags', []))} tags, {len(seo_data_debug.get('settings', {}).get('keywords', []))} keywords")
# Log sample SEO tags (first 2 only to avoid too much output)
if seo_data_debug.get('tags'):
logger.warning("📋 SEO Tags sample:")
for i, tag in enumerate(seo_data_debug['tags'][:2]): # First 2 tags
logger.warning(f" Tag {i+1}: type={tag.get('type')}, custom={tag.get('custom')}, disabled={tag.get('disabled')}")
if len(seo_data_debug['tags']) > 2:
logger.warning(f" ... and {len(seo_data_debug['tags']) - 2} more tags")
if seo_data_debug.get('settings', {}).get('keywords'):
keywords_list = [k.get('term') for k in seo_data_debug['settings']['keywords'][:3]]
logger.warning(f"🔑 Keywords: {keywords_list}")
# Log FULL seoData structure for debugging
import json
try:
seo_json = json.dumps(seo_data_debug, indent=2, ensure_ascii=False)
logger.warning(f"📄 FULL seoData JSON:\n{seo_json[:2000]}...") # First 2000 chars
except Exception as e:
logger.error(f"Failed to serialize seoData: {e}")
else:
logger.warning("⚠️ No seoData in draft post payload!")
try:
# Add wix-site-id header if we can extract it from token
# Extract wix-site-id from token if possible
extra_headers = {}
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
@@ -423,12 +536,8 @@ def create_blog_post(
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
headers['wix-site-id'] = meta_site_id
except Exception as e:
logger.debug(f"Could not extract site ID from token: {e}")
# Make the API call
logger.warning(f"🚀 Calling Wix API: POST /blog/v3/draft-posts")
logger.warning(f"📦 Payload: title='{blog_data['draftPost'].get('title')}', has_seoData={'seoData' in blog_data['draftPost']}, has_richContent={'richContent' in blog_data['draftPost']}")
except Exception:
pass
# Validate payload structure before sending
draft_post = blog_data.get('draftPost', {})
@@ -617,88 +726,13 @@ def create_blog_post(
logger.warning(f"📤 RichContent has metadata: {bool(blog_data['draftPost']['richContent'].get('metadata'))}")
logger.warning(f"📤 RichContent has documentStyle: {bool(blog_data['draftPost']['richContent'].get('documentStyle'))}")
# Try sending WITHOUT SEO data first to isolate the issue
test_without_seo = False # Disabled - listItemData issue fixed
if test_without_seo and 'seoData' in blog_data['draftPost']:
logger.warning("🧪 TESTING WITHOUT SEO DATA to isolate issue...")
# Clone the payload without SEO data
test_payload_no_seo = {
'draftPost': {
'title': blog_data['draftPost']['title'],
'memberId': blog_data['draftPost']['memberId'],
'richContent': blog_data['draftPost']['richContent'],
'excerpt': blog_data['draftPost'].get('excerpt', '')
},
'publish': False,
'fieldsets': ['URL']
}
try:
logger.warning("🧪 Attempting without SEO data...")
test_result = blog_service.create_draft_post(access_token, test_payload_no_seo, extra_headers or None)
logger.warning(f"✅ WITHOUT SEO DATA SUCCEEDED! Post ID: {test_result.get('draftPost', {}).get('id')}")
logger.error("⚠️⚠️⚠️ ISSUE IS WITH SEO DATA STRUCTURE!")
# If this succeeds, don't send the full payload, just return this result
return test_result
except Exception as e:
logger.warning(f"❌ WITHOUT SEO DATA ALSO FAILED: {e}")
logger.warning("⚠️ Issue is NOT with SEO data, continuing with full payload...")
# Try sending with minimal structure first to isolate the issue
# Create a test payload with just required fields
minimal_test = False # Set to True to test with minimal payload
if minimal_test:
logger.warning("🧪 TESTING WITH MINIMAL PAYLOAD (title + memberId + simple richContent)")
test_payload = {
'draftPost': {
'title': blog_data['draftPost']['title'],
'memberId': blog_data['draftPost']['memberId'],
'richContent': {
'nodes': [
{
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [
{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'textData': {
'text': 'Test paragraph',
'decorations': []
}
}
],
'paragraphData': {}
}
],
'metadata': {'version': 1, 'id': str(uuid.uuid4())},
'documentStyle': {}
}
},
'publish': False,
'fieldsets': ['URL']
}
logger.warning("🧪 Attempting minimal payload first...")
try:
test_result = blog_service.create_draft_post(access_token, test_payload, extra_headers or None)
logger.warning(f"✅ MINIMAL PAYLOAD SUCCEEDED! Post ID: {test_result.get('draftPost', {}).get('id')}")
logger.warning("⚠️ Issue is with complex content, not basic structure")
except Exception as e:
logger.error(f"❌ MINIMAL PAYLOAD ALSO FAILED: {e}")
logger.error("⚠️ Issue is with basic structure or permissions")
result = blog_service.create_draft_post(access_token, blog_data, extra_headers or None)
# Log response
# Log success
draft_post = result.get('draftPost', {})
logger.warning(f"✅ Blog post created successfully! Post ID: {draft_post.get('id', 'N/A')}")
# Check if SEO data was preserved in response
if 'seoData' in draft_post:
seo_response = draft_post['seoData']
logger.warning(f"✅ SEO data confirmed in response: {len(seo_response.get('tags', []))} tags, {len(seo_response.get('settings', {}).get('keywords', []))} keywords")
else:
logger.warning("⚠️ No seoData in response - it may have been filtered out by Wix API")
logger.warning(f"📋 Response fields: {list(draft_post.keys())}")
post_id = draft_post.get('id', 'N/A')
wix_logger.log_operation_result("Create Draft Post", True, result)
logger.success(f"✅ Wix: Blog post created - ID: {post_id}")
return result
except requests.RequestException as e:

View File

@@ -13,6 +13,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
return [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {'text': '', 'decorations': []}
}]
@@ -32,6 +33,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
@@ -46,11 +48,14 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
# Recursively parse the bold text for nested formatting
bold_nodes = parse_markdown_inline(bold_text)
# Add BOLD decoration to all text nodes within
# Per Wix API: decorations are objects with 'type' field, not strings
for node in bold_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
if 'BOLD' not in node_decorations:
node_decorations.append('BOLD')
# Check if BOLD decoration already exists
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
if not has_bold:
node_decorations.append({'type': 'BOLD'})
node['textData']['decorations'] = node_decorations
nodes.append(node)
i = end_bold + 2
@@ -63,6 +68,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
@@ -79,24 +85,23 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
url_end = text.find(')', url_start)
if url_end != -1:
url = text[url_start:url_end]
# Create link node
link_node_id = str(uuid.uuid4())
text_node_id = str(uuid.uuid4())
link_text_nodes = parse_markdown_inline(link_text)
# Wrap link text in LINK node
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
# Create TEXT node with LINK decoration
nodes.append({
'id': link_node_id,
'type': 'LINK',
'nodes': link_text_nodes if link_text_nodes else [{
'id': text_node_id,
'type': 'TEXT',
'textData': {'text': link_text, 'decorations': []}
}],
'linkData': {
'link': {
'url': url,
'target': '_blank'
}
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': link_text,
'decorations': [{
'type': 'LINK',
'linkData': {
'link': {
'url': url,
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
}
}
}]
}
})
i = url_end + 1
@@ -109,6 +114,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
@@ -121,12 +127,16 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
code_end = text.find('`', i + 1)
if code_end != -1:
code_text = text[i + 1:code_end]
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
# For inline code, we'll just use plain text for now
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': code_text,
'decorations': ['CODE']
'decorations': [] # CODE is not a valid decoration in Wix API
}
})
i = code_end + 1
@@ -139,6 +149,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
@@ -155,11 +166,14 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
italic_text = text[i + 1:italic_end]
italic_nodes = parse_markdown_inline(italic_text)
# Add ITALIC decoration
# Per Wix API: decorations are objects with 'type' field
for node in italic_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
if 'ITALIC' not in node_decorations:
node_decorations.append('ITALIC')
# Check if ITALIC decoration already exists
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
if not has_italic:
node_decorations.append({'type': 'ITALIC'})
node['textData']['decorations'] = node_decorations
nodes.append(node)
i = italic_end + 1
@@ -174,6 +188,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
@@ -185,6 +200,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': text,
'decorations': []
@@ -439,6 +455,7 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': content[:500] if content else "This is a post from ALwrity.",
'decorations': []
@@ -448,14 +465,11 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
}
nodes.append(fallback_paragraph)
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
return {
'type': 'DOCUMENT',
'id': str(uuid.uuid4()),
'nodes': nodes,
'metadata': {'version': 1, 'id': str(uuid.uuid4())},
'documentStyle': {
'paragraph': {'decorations': [], 'nodeStyle': {}, 'lineHeight': '1.5'}
}
'nodes': nodes
}

View File

@@ -0,0 +1,118 @@
"""
Intelligent logging utility for Wix operations.
Aggregates and consolidates logs to reduce console noise.
"""
from typing import Dict, Any, Optional, List
from loguru import logger
import json
class WixLogger:
"""Consolidated logger for Wix operations"""
def __init__(self):
self.context: Dict[str, Any] = {}
self.errors: List[str] = []
self.warnings: List[str] = []
def reset(self):
"""Reset context for new operation"""
self.context = {}
self.errors = []
self.warnings = []
def set_context(self, key: str, value: Any):
"""Store context information"""
self.context[key] = value
def add_error(self, message: str):
"""Add error message"""
self.errors.append(message)
def add_warning(self, message: str):
"""Add warning message"""
self.warnings.append(message)
def log_operation_start(self, operation: str, **kwargs):
"""Log start of operation with aggregated context"""
logger.info(f"🚀 Wix: {operation}")
if kwargs:
summary = ", ".join([f"{k}={v}" for k, v in kwargs.items() if v])
if summary:
logger.info(f" {summary}")
def log_operation_result(self, operation: str, success: bool, result: Optional[Dict] = None, error: Optional[str] = None):
"""Log operation result"""
if success:
post_id = result.get('draftPost', {}).get('id') if result else None
if post_id:
logger.success(f"✅ Wix: {operation} - Post ID: {post_id}")
else:
logger.success(f"✅ Wix: {operation} - Success")
else:
logger.error(f"❌ Wix: {operation} - {error or 'Failed'}")
def log_api_call(self, method: str, endpoint: str, status_code: int,
payload_summary: Optional[Dict] = None, error_body: Optional[Dict] = None):
"""Log API call with aggregated information"""
status_emoji = "" if status_code < 400 else ""
logger.info(f"{status_emoji} Wix API: {method} {endpoint}{status_code}")
if payload_summary:
# Show only key information
if 'draftPost' in payload_summary:
dp = payload_summary['draftPost']
parts = []
if 'title' in dp:
parts.append(f"title='{str(dp['title'])[:50]}...'")
if 'richContent' in dp:
nodes_count = len(dp['richContent'].get('nodes', []))
parts.append(f"nodes={nodes_count}")
if 'seoData' in dp:
parts.append("has_seoData")
if parts:
logger.debug(f" Payload: {', '.join(parts)}")
if error_body and status_code >= 400:
error_msg = error_body.get('message', 'Unknown error')
logger.error(f" Error: {error_msg}")
if status_code == 500:
logger.error(" ⚠️ Internal server error - check Wix API status")
elif status_code == 403:
logger.error(" ⚠️ Permission denied - verify OAuth app has BLOG.CREATE-DRAFT")
elif status_code == 401:
logger.error(" ⚠️ Unauthorized - token may be expired")
def log_token_info(self, token_length: int, has_blog_scope: Optional[bool] = None,
meta_site_id: Optional[str] = None):
"""Log token information (aggregated)"""
info_parts = [f"Token: {token_length} chars"]
if has_blog_scope is not None:
info_parts.append(f"Blog scope: {'' if has_blog_scope else ''}")
if meta_site_id:
info_parts.append(f"Site ID: {meta_site_id[:20]}...")
logger.debug(f"🔐 Wix Auth: {', '.join(info_parts)}")
def log_ricos_conversion(self, nodes_count: int, method: str = "custom parser"):
"""Log Ricos conversion result"""
logger.info(f"📝 Wix Ricos: Converted to {nodes_count} nodes ({method})")
def log_seo_data(self, tags_count: int, keywords_count: int):
"""Log SEO data summary"""
logger.info(f"🔍 Wix SEO: {tags_count} tags, {keywords_count} keywords")
def log_final_summary(self):
"""Log final aggregated summary"""
if self.errors:
logger.error(f"⚠️ Wix Operation: {len(self.errors)} error(s)")
for err in self.errors[-3:]: # Show last 3 errors
logger.error(f" {err}")
elif self.warnings:
logger.warning(f"⚠️ Wix Operation: {len(self.warnings)} warning(s)")
else:
logger.success("✅ Wix Operation: No issues detected")
# Global instance
wix_logger = WixLogger()

View File

@@ -148,6 +148,9 @@ def convert_via_wix_api(markdown_content: str, access_token: str, base_url: str
Convert markdown to Ricos using Wix's official Ricos Documents API.
Uses HTML format for better reliability (per Wix documentation, HTML is fully supported).
Wix API Limitation: HTML content must be 10,000 characters or less.
If content exceeds this limit, it will be truncated with an ellipsis.
Reference: https://dev.wix.com/docs/api-reference/assets/rich-content/ricos-documents/convert-to-ricos-document
Args:
@@ -182,6 +185,28 @@ def convert_via_wix_api(markdown_content: str, access_token: str, base_url: str
else:
html_content = html_stripped
# CRITICAL: Wix API has a 10,000 character limit for HTML content
# If content exceeds this limit, truncate intelligently at paragraph boundaries
MAX_HTML_LENGTH = 10000
if len(html_content) > MAX_HTML_LENGTH:
logger.warning(f"⚠️ HTML content ({len(html_content)} chars) exceeds Wix API limit of {MAX_HTML_LENGTH} chars")
# Try to truncate at a paragraph boundary to avoid breaking HTML tags
truncate_at = MAX_HTML_LENGTH - 100 # Leave room for closing tags and ellipsis
# Look for the last </p> tag before the truncation point
last_p_close = html_content.rfind('</p>', 0, truncate_at)
if last_p_close > 0:
html_content = html_content[:last_p_close + 4] # Include the </p> tag
else:
# If no paragraph boundary found, just truncate
html_content = html_content[:truncate_at]
# Add an ellipsis paragraph to indicate truncation
html_content += '<p><em>... (Content truncated due to length constraints)</em></p>'
logger.warning(f"✅ Truncated HTML to {len(html_content)} chars (at paragraph boundary)")
logger.debug(f"✅ Converted markdown to HTML: {len(html_content)} chars, preview: {html_content[:200]}...")
headers = {

View File

@@ -27,7 +27,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
"""
seo_data = {
'settings': {
'keywords': []
'keywords': [],
'preventAutoRedirect': False # Required by Wix API schema
},
'tags': []
}
@@ -40,7 +41,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
if focus_keyword:
keywords_list.append({
'term': str(focus_keyword),
'isMain': True
'isMain': True,
'origin': 'USER' # Required by Wix API
})
# Add additional keywords from blog_tags or other sources
@@ -51,7 +53,8 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
if tag_str and tag_str != focus_keyword: # Don't duplicate main keyword
keywords_list.append({
'term': tag_str,
'isMain': False
'isMain': False,
'origin': 'USER' # Required by Wix API
})
# Add social hashtags as keywords if available
@@ -63,9 +66,17 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
if hashtag_str and hashtag_str != focus_keyword:
keywords_list.append({
'term': hashtag_str,
'isMain': False
'isMain': False,
'origin': 'USER' # Required by Wix API
})
# CRITICAL: Wix Blog API limits keywords to maximum 5
# Prioritize: main keyword first, then most important additional keywords
if len(keywords_list) > 5:
logger.warning(f"Truncating keywords from {len(keywords_list)} to 5 (Wix API limit)")
# Keep main keyword + next 4 most important
keywords_list = keywords_list[:5]
seo_data['settings']['keywords'] = keywords_list
# Validate keywords list is not empty (or ensure at least one keyword exists)
@@ -89,13 +100,13 @@ def build_seo_data(seo_metadata: Dict[str, Any], default_title: str = None) -> O
})
# SEO title - 'title' type uses 'children' field, not 'props.content'
# Per Wix API example: title tags don't need 'custom' or 'disabled' fields
seo_title = seo_metadata.get('seo_title') or default_title
if seo_title:
tags_list.append({
'type': 'title',
'children': str(seo_title), # Title tags use 'children', not 'props.content'
'custom': True,
'disabled': False
'children': str(seo_title) # Title tags use 'children', not 'props.content'
# Note: Wix example doesn't show 'custom' or 'disabled' for title tags
})
# Open Graph tags

View File

@@ -0,0 +1,378 @@
"""
Failure Detection Service
Analyzes execution logs to detect failure patterns and mark tasks for human intervention.
"""
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from sqlalchemy.orm import Session
from enum import Enum
import json
from utils.logger_utils import get_service_logger
logger = get_service_logger("failure_detection")
class FailureReason(Enum):
"""Categories of failure reasons."""
API_LIMIT = "api_limit" # 429, rate limits, quota exceeded
AUTH_ERROR = "auth_error" # 401, 403, token expired
NETWORK_ERROR = "network_error" # Connection errors, timeouts
CONFIG_ERROR = "config_error" # Missing config, invalid parameters
UNKNOWN = "unknown" # Other errors
class FailurePattern:
"""Represents a failure pattern for a task."""
def __init__(
self,
task_id: int,
task_type: str,
user_id: str,
consecutive_failures: int,
recent_failures: int,
failure_reason: FailureReason,
last_failure_time: Optional[datetime],
error_patterns: List[str],
should_cool_off: bool
):
self.task_id = task_id
self.task_type = task_type
self.user_id = user_id
self.consecutive_failures = consecutive_failures
self.recent_failures = recent_failures
self.failure_reason = failure_reason
self.last_failure_time = last_failure_time
self.error_patterns = error_patterns
self.should_cool_off = should_cool_off
class FailureDetectionService:
"""Service for detecting failure patterns in task execution logs."""
# Cool-off thresholds
CONSECUTIVE_FAILURE_THRESHOLD = 3 # 3 consecutive failures
RECENT_FAILURE_THRESHOLD = 5 # 5 failures in last 7 days
COOL_OFF_PERIOD_DAYS = 7 # Cool-off period after marking for intervention
def __init__(self, db: Session):
self.db = db
self.logger = logger
def analyze_task_failures(
self,
task_id: int,
task_type: str,
user_id: str
) -> Optional[FailurePattern]:
"""
Analyze failure patterns for a specific task.
Args:
task_id: Task ID
task_type: Task type (oauth_token_monitoring, website_analysis, etc.)
user_id: User ID
Returns:
FailurePattern if pattern detected, None otherwise
"""
try:
# Get execution logs for this task
execution_logs = self._get_execution_logs(task_id, task_type)
if not execution_logs:
return None
# Analyze failure patterns
consecutive_failures = self._count_consecutive_failures(execution_logs)
recent_failures = self._count_recent_failures(execution_logs, days=7)
failure_reason = self._classify_failure_reason(execution_logs)
error_patterns = self._extract_error_patterns(execution_logs)
last_failure_time = self._get_last_failure_time(execution_logs)
# Determine if task should be cooled off
should_cool_off = (
consecutive_failures >= self.CONSECUTIVE_FAILURE_THRESHOLD or
recent_failures >= self.RECENT_FAILURE_THRESHOLD
)
if should_cool_off:
self.logger.warning(
f"Failure pattern detected for task {task_id} ({task_type}): "
f"consecutive={consecutive_failures}, recent={recent_failures}, "
f"reason={failure_reason.value}"
)
return FailurePattern(
task_id=task_id,
task_type=task_type,
user_id=user_id,
consecutive_failures=consecutive_failures,
recent_failures=recent_failures,
failure_reason=failure_reason,
last_failure_time=last_failure_time,
error_patterns=error_patterns,
should_cool_off=should_cool_off
)
except Exception as e:
self.logger.error(f"Error analyzing task failures for task {task_id}: {e}", exc_info=True)
return None
def _get_execution_logs(self, task_id: int, task_type: str) -> List[Dict[str, Any]]:
"""Get execution logs for a task."""
try:
if task_type == "oauth_token_monitoring":
from models.oauth_token_monitoring_models import OAuthTokenExecutionLog
logs = self.db.query(OAuthTokenExecutionLog).filter(
OAuthTokenExecutionLog.task_id == task_id
).order_by(OAuthTokenExecutionLog.execution_date.desc()).all()
return [
{
"status": log.status,
"error_message": log.error_message,
"execution_date": log.execution_date,
"result_data": log.result_data
}
for log in logs
]
elif task_type == "website_analysis":
from models.website_analysis_monitoring_models import WebsiteAnalysisExecutionLog
logs = self.db.query(WebsiteAnalysisExecutionLog).filter(
WebsiteAnalysisExecutionLog.task_id == task_id
).order_by(WebsiteAnalysisExecutionLog.execution_date.desc()).all()
return [
{
"status": log.status,
"error_message": log.error_message,
"execution_date": log.execution_date,
"result_data": log.result_data
}
for log in logs
]
elif task_type in ["gsc_insights", "bing_insights", "platform_insights"]:
from models.platform_insights_monitoring_models import PlatformInsightsExecutionLog
logs = self.db.query(PlatformInsightsExecutionLog).filter(
PlatformInsightsExecutionLog.task_id == task_id
).order_by(PlatformInsightsExecutionLog.execution_date.desc()).all()
return [
{
"status": log.status,
"error_message": log.error_message,
"execution_date": log.execution_date,
"result_data": log.result_data
}
for log in logs
]
else:
# Fallback to monitoring_task execution logs
from models.monitoring_models import TaskExecutionLog
logs = self.db.query(TaskExecutionLog).filter(
TaskExecutionLog.task_id == task_id
).order_by(TaskExecutionLog.execution_date.desc()).all()
return [
{
"status": log.status,
"error_message": log.error_message,
"execution_date": log.execution_date,
"result_data": log.result_data
}
for log in logs
]
except Exception as e:
self.logger.error(f"Error getting execution logs for task {task_id}: {e}", exc_info=True)
return []
def _count_consecutive_failures(self, logs: List[Dict[str, Any]]) -> int:
"""Count consecutive failures from most recent."""
count = 0
for log in logs:
if log["status"] == "failed":
count += 1
else:
break # Stop at first success
return count
def _count_recent_failures(self, logs: List[Dict[str, Any]], days: int = 7) -> int:
"""Count failures in the last N days."""
cutoff = datetime.utcnow() - timedelta(days=days)
return sum(
1 for log in logs
if log["status"] == "failed" and log["execution_date"] >= cutoff
)
def _classify_failure_reason(self, logs: List[Dict[str, Any]]) -> FailureReason:
"""Classify the primary failure reason from error messages."""
# Check most recent failures first
recent_failures = [log for log in logs if log["status"] == "failed"][:5]
for log in recent_failures:
error_message = (log.get("error_message") or "").lower()
result_data = log.get("result_data") or {}
# Check for API limits (429)
if "429" in error_message or "rate limit" in error_message or "limit reached" in error_message:
return FailureReason.API_LIMIT
# Check result_data for API limit info
if isinstance(result_data, dict):
if result_data.get("error_status") == 429:
return FailureReason.API_LIMIT
if "limit" in str(result_data).lower() and "reached" in str(result_data).lower():
return FailureReason.API_LIMIT
# Check for usage info indicating limits
usage_info = result_data.get("usage_info", {})
if isinstance(usage_info, dict):
if usage_info.get("usage_percentage", 0) >= 100:
return FailureReason.API_LIMIT
# Check for auth errors
if "401" in error_message or "403" in error_message or "unauthorized" in error_message or "forbidden" in error_message:
return FailureReason.AUTH_ERROR
if "token" in error_message and ("expired" in error_message or "invalid" in error_message):
return FailureReason.AUTH_ERROR
# Check for network errors
if "timeout" in error_message or "connection" in error_message or "network" in error_message:
return FailureReason.NETWORK_ERROR
# Check for config errors
if "config" in error_message or "missing" in error_message or "invalid" in error_message:
return FailureReason.CONFIG_ERROR
return FailureReason.UNKNOWN
def _extract_error_patterns(self, logs: List[Dict[str, Any]]) -> List[str]:
"""Extract common error patterns from failure logs."""
patterns = []
recent_failures = [log for log in logs if log["status"] == "failed"][:5]
for log in recent_failures:
error_message = log.get("error_message") or ""
if error_message:
# Extract key phrases (first 100 chars)
pattern = error_message[:100].strip()
if pattern and pattern not in patterns:
patterns.append(pattern)
return patterns[:3] # Return top 3 patterns
def _get_last_failure_time(self, logs: List[Dict[str, Any]]) -> Optional[datetime]:
"""Get the timestamp of the most recent failure."""
for log in logs:
if log["status"] == "failed":
return log["execution_date"]
return None
def get_tasks_needing_intervention(
self,
user_id: Optional[str] = None,
task_type: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get all tasks that need human intervention.
Args:
user_id: Optional user ID filter
task_type: Optional task type filter
Returns:
List of task dictionaries with failure pattern info
"""
try:
tasks_needing_intervention = []
# Check OAuth token monitoring tasks
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
oauth_tasks = self.db.query(OAuthTokenMonitoringTask).filter(
OAuthTokenMonitoringTask.status == "needs_intervention"
)
if user_id:
oauth_tasks = oauth_tasks.filter(OAuthTokenMonitoringTask.user_id == user_id)
for task in oauth_tasks.all():
pattern = self.analyze_task_failures(task.id, "oauth_token_monitoring", task.user_id)
if pattern:
tasks_needing_intervention.append({
"task_id": task.id,
"task_type": "oauth_token_monitoring",
"user_id": task.user_id,
"platform": task.platform,
"failure_pattern": {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"last_failure_time": pattern.last_failure_time.isoformat() if pattern.last_failure_time else None,
"error_patterns": pattern.error_patterns
},
"failure_reason": task.failure_reason,
"last_failure": task.last_failure.isoformat() if task.last_failure else None
})
# Check website analysis tasks
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
website_tasks = self.db.query(WebsiteAnalysisTask).filter(
WebsiteAnalysisTask.status == "needs_intervention"
)
if user_id:
website_tasks = website_tasks.filter(WebsiteAnalysisTask.user_id == user_id)
for task in website_tasks.all():
pattern = self.analyze_task_failures(task.id, "website_analysis", task.user_id)
if pattern:
tasks_needing_intervention.append({
"task_id": task.id,
"task_type": "website_analysis",
"user_id": task.user_id,
"website_url": task.website_url,
"failure_pattern": {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"last_failure_time": pattern.last_failure_time.isoformat() if pattern.last_failure_time else None,
"error_patterns": pattern.error_patterns
},
"failure_reason": task.failure_reason,
"last_failure": task.last_failure.isoformat() if task.last_failure else None
})
# Check platform insights tasks
from models.platform_insights_monitoring_models import PlatformInsightsTask
insights_tasks = self.db.query(PlatformInsightsTask).filter(
PlatformInsightsTask.status == "needs_intervention"
)
if user_id:
insights_tasks = insights_tasks.filter(PlatformInsightsTask.user_id == user_id)
for task in insights_tasks.all():
task_type_str = f"{task.platform}_insights"
pattern = self.analyze_task_failures(task.id, task_type_str, task.user_id)
if pattern:
tasks_needing_intervention.append({
"task_id": task.id,
"task_type": task_type_str,
"user_id": task.user_id,
"platform": task.platform,
"failure_pattern": {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"last_failure_time": pattern.last_failure_time.isoformat() if pattern.last_failure_time else None,
"error_patterns": pattern.error_patterns
},
"failure_reason": task.failure_reason,
"last_failure": task.last_failure.isoformat() if task.last_failure else None
})
return tasks_needing_intervention
except Exception as e:
self.logger.error(f"Error getting tasks needing intervention: {e}", exc_info=True)
return []

View File

@@ -22,7 +22,8 @@ async def execute_task_async(
scheduler: 'TaskScheduler',
task_type: str,
task: Any,
summary: Optional[Dict[str, Any]] = None
summary: Optional[Dict[str, Any]] = None,
execution_source: str = "scheduler" # "scheduler" or "manual"
):
"""
Execute a single task asynchronously with user isolation.
@@ -98,6 +99,19 @@ async def execute_task_async(
except Exception as e:
logger.debug(f"Could not extract user_id after merge for task {task_id}: {e}")
# Check if task is in cool-off (skip if scheduler-triggered, allow if manual)
if execution_source == "scheduler":
if hasattr(task, 'status') and task.status == "needs_intervention":
logger.warning(
f"[Scheduler] ⏸️ Skipping task {task_id} - marked for human intervention. "
f"Use manual trigger to retry."
)
scheduler.stats['tasks_skipped'] += 1
if summary:
summary.setdefault('skipped', 0)
summary['skipped'] += 1
return
# Get executor for this task type
try:
executor = scheduler.registry.get_executor(task_type)

View File

@@ -86,6 +86,9 @@ class BingInsightsExecutor(TaskExecutor):
task.last_success = datetime.utcnow()
task.status = 'active'
task.failure_reason = None
# Reset failure tracking on success
task.consecutive_failures = 0
task.failure_pattern = None
# Schedule next check (7 days from now)
task.next_check = self.calculate_next_execution(
task=task,
@@ -93,11 +96,41 @@ class BingInsightsExecutor(TaskExecutor):
last_execution=task.last_check
)
else:
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "bing_insights", task.user_id
)
task.last_failure = datetime.utcnow()
task.failure_reason = result.error_message
task.status = 'failed'
# Schedule retry in 1 day
task.next_check = datetime.utcnow() + timedelta(days=1)
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
# Clear next_check - task won't run automatically
task.next_check = None
self.logger.warning(
f"Task {task.id} marked for human intervention: "
f"{pattern.consecutive_failures} consecutive failures, "
f"reason: {pattern.failure_reason.value}"
)
else:
# Normal failure handling
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
# Schedule retry in 1 day
task.next_check = datetime.utcnow() + timedelta(days=1)
task.updated_at = datetime.utcnow()
db.commit()
@@ -117,12 +150,35 @@ class BingInsightsExecutor(TaskExecutor):
context="Bing insights fetch"
)
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "bing_insights", task.user_id
)
# Update task
task.last_check = datetime.utcnow()
task.last_failure = datetime.utcnow()
task.failure_reason = str(e)
task.status = 'failed'
task.next_check = datetime.utcnow() + timedelta(days=1)
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
task.next_check = None
else:
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
task.next_check = datetime.utcnow() + timedelta(days=1)
task.updated_at = datetime.utcnow()
db.commit()

View File

@@ -85,6 +85,9 @@ class GSCInsightsExecutor(TaskExecutor):
task.last_success = datetime.utcnow()
task.status = 'active'
task.failure_reason = None
# Reset failure tracking on success
task.consecutive_failures = 0
task.failure_pattern = None
# Schedule next check (7 days from now)
task.next_check = self.calculate_next_execution(
task=task,
@@ -92,11 +95,41 @@ class GSCInsightsExecutor(TaskExecutor):
last_execution=task.last_check
)
else:
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "gsc_insights", task.user_id
)
task.last_failure = datetime.utcnow()
task.failure_reason = result.error_message
task.status = 'failed'
# Schedule retry in 1 day
task.next_check = datetime.utcnow() + timedelta(days=1)
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
# Clear next_check - task won't run automatically
task.next_check = None
self.logger.warning(
f"Task {task.id} marked for human intervention: "
f"{pattern.consecutive_failures} consecutive failures, "
f"reason: {pattern.failure_reason.value}"
)
else:
# Normal failure handling
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
# Schedule retry in 1 day
task.next_check = datetime.utcnow() + timedelta(days=1)
task.updated_at = datetime.utcnow()
db.commit()
@@ -116,12 +149,35 @@ class GSCInsightsExecutor(TaskExecutor):
context="GSC insights fetch"
)
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "gsc_insights", task.user_id
)
# Update task
task.last_check = datetime.utcnow()
task.last_failure = datetime.utcnow()
task.failure_reason = str(e)
task.status = 'failed'
task.next_check = datetime.utcnow() + timedelta(days=1)
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
task.next_check = None
else:
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
task.next_check = datetime.utcnow() + timedelta(days=1)
task.updated_at = datetime.utcnow()
db.commit()

View File

@@ -92,6 +92,9 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
task.last_success = datetime.utcnow()
task.status = 'active'
task.failure_reason = None
# Reset failure tracking on success
task.consecutive_failures = 0
task.failure_pattern = None
# Schedule next check (7 days from now)
task.next_check = self.calculate_next_execution(
task=task,
@@ -99,14 +102,44 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
last_execution=task.last_check
)
else:
# Refresh failed - mark as failed and stop automatic retries
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "oauth_token_monitoring", task.user_id
)
task.last_failure = datetime.utcnow()
task.failure_reason = result.error_message
task.status = 'failed'
# Do NOT update next_check - wait for manual trigger
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
# Clear next_check - task won't run automatically
task.next_check = None
self.logger.warning(
f"Task {task.id} marked for human intervention: "
f"{pattern.consecutive_failures} consecutive failures, "
f"reason: {pattern.failure_reason.value}"
)
else:
# Normal failure handling
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
# Do NOT update next_check - wait for manual trigger
self.logger.warning(
f"OAuth token refresh failed for user {user_id}, platform {platform}. "
f"Task marked as failed. No automatic retry will be scheduled."
f"{'Task marked for human intervention' if pattern and pattern.should_cool_off else 'Task marked as failed. No automatic retry will be scheduled.'}"
)
# Create UsageAlert notification for the user

View File

@@ -106,6 +106,9 @@ class WebsiteAnalysisExecutor(TaskExecutor):
task.last_success = datetime.utcnow()
task.status = 'active'
task.failure_reason = None
# Reset failure tracking on success
task.consecutive_failures = 0
task.failure_pattern = None
# Schedule next check based on frequency_days
task.next_check = self.calculate_next_execution(
task=task,
@@ -123,17 +126,48 @@ class WebsiteAnalysisExecutor(TaskExecutor):
)
return result
else:
# Analyze failure pattern
from services.scheduler.core.failure_detection_service import FailureDetectionService
failure_detection = FailureDetectionService(db)
pattern = failure_detection.analyze_task_failures(
task.id, "website_analysis", task.user_id
)
task.last_failure = datetime.utcnow()
task.failure_reason = result.error_message
task.status = 'failed'
# Do NOT update next_check - wait for manual retry
if pattern and pattern.should_cool_off:
# Mark task for human intervention
task.status = "needs_intervention"
task.consecutive_failures = pattern.consecutive_failures
task.failure_pattern = {
"consecutive_failures": pattern.consecutive_failures,
"recent_failures": pattern.recent_failures,
"failure_reason": pattern.failure_reason.value,
"error_patterns": pattern.error_patterns,
"cool_off_until": (datetime.utcnow() + timedelta(days=7)).isoformat()
}
# Clear next_check - task won't run automatically
task.next_check = None
self.logger.warning(
f"Task {task.id} marked for human intervention: "
f"{pattern.consecutive_failures} consecutive failures, "
f"reason: {pattern.failure_reason.value}"
)
else:
# Normal failure handling
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
# Do NOT update next_check - wait for manual retry
# Commit all changes to database
db.commit()
self.logger.warning(
f"Website analysis failed for task {task.id}. "
f"Error: {result.error_message}. Waiting for manual retry."
f"Error: {result.error_message}. "
f"{'Marked for human intervention' if pattern and pattern.should_cool_off else 'Waiting for manual retry'}."
)
return result

View File

@@ -0,0 +1,96 @@
# Story Writer Service
Story generation service using prompt chaining approach, migrated from `ToBeMigrated/ai_writers/ai_story_writer/`.
## Structure
```
backend/
├── services/
│ └── story_writer/
│ ├── __init__.py
│ ├── story_service.py # Core story generation logic
│ └── README.md
├── api/
│ └── story_writer/
│ ├── __init__.py
│ ├── router.py # API endpoints
│ ├── task_manager.py # Async task management
│ └── cache_manager.py # Result caching
└── models/
└── story_models.py # Pydantic models
```
## Features
- **Prompt Chaining**: Generates stories through premise → outline → start → continuation
- **Multiple Personas**: Supports 11 predefined author personas/genres
- **Configurable Parameters**:
- Story setting, characters, plot elements
- Writing style, tone, narrative POV
- Audience age group, content rating, ending preference
- **Subscription Integration**: Automatic usage tracking via `main_text_generation`
- **Provider Support**: Works with both Gemini and HuggingFace
- **Async Task Management**: Long-running story generation with polling
- **Caching**: Result caching for identical requests
## API Endpoints
### Synchronous Endpoints
- `POST /api/story/generate-premise` - Generate story premise
- `POST /api/story/generate-outline` - Generate outline from premise
- `POST /api/story/generate-start` - Generate story beginning
- `POST /api/story/continue` - Continue story generation
### Asynchronous Endpoints
- `POST /api/story/generate-full` - Generate complete story (returns task_id)
- `GET /api/story/task/{task_id}/status` - Get task status
- `GET /api/story/task/{task_id}/result` - Get completed task result
### Cache Management
- `GET /api/story/cache/stats` - Get cache statistics
- `POST /api/story/cache/clear` - Clear cache
## Usage Example
```python
from services.story_writer.story_service import StoryWriterService
service = StoryWriterService()
# Generate full story
result = service.generate_full_story(
persona="Award-Winning Science Fiction Author",
story_setting="A bustling futuristic city in 2150",
character_input="John, a tall muscular man with a kind heart",
plot_elements="The hero's journey, Good vs. evil",
writing_style="Formal",
story_tone="Suspenseful",
narrative_pov="Third Person Limited",
audience_age_group="Adults",
content_rating="PG-13",
ending_preference="Happy",
user_id="clerk_user_id",
max_iterations=10
)
print(result["premise"])
print(result["outline"])
print(result["story"])
```
## Migration Notes
- Updated imports from legacy `...gpt_providers.text_generation.main_text_generation` to `services.llm_providers.main_text_generation`
- Added `user_id` parameter to all LLM calls for subscription support
- Removed Streamlit dependencies (UI moved to frontend)
- Added proper error handling with HTTPException support
- Added async task management for long-running operations
- Added caching support for identical requests
## Integration
The router is automatically registered via `alwrity_utils/router_manager.py` in the optional routers section.

View File

@@ -0,0 +1,10 @@
"""
Story Writer Service
Provides story generation functionality using prompt chaining.
Supports multiple personas, styles, and iterative story generation.
"""
from .story_service import StoryWriterService
__all__ = ['StoryWriterService']

View File

@@ -0,0 +1,291 @@
"""
Audio Generation Service for Story Writer
Generates audio narration for story scenes using TTS (Text-to-Speech) providers.
"""
import os
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from loguru import logger
from fastapi import HTTPException
class StoryAudioGenerationService:
"""Service for generating audio narration for story scenes."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the audio generation service.
Parameters:
output_dir (str, optional): Directory to save generated audio files.
Defaults to 'backend/story_audio' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_audio directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_audio"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}")
def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene audio file."""
# Clean scene title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"scene_{scene_number}_{clean_title}_{unique_id}.mp3"
def _generate_audio_gtts(
self,
text: str,
output_path: Path,
lang: str = "en",
slow: bool = False
) -> bool:
"""
Generate audio using Google Text-to-Speech (gTTS).
Parameters:
text (str): Text to convert to speech.
output_path (Path): Path to save the audio file.
lang (str): Language code (default: "en").
slow (bool): Whether to speak slowly (default: False).
Returns:
bool: True if generation was successful, False otherwise.
"""
try:
from gtts import gTTS
# Generate speech
tts = gTTS(text=text, lang=lang, slow=slow)
# Save to file
tts.save(str(output_path))
logger.info(f"[StoryAudioGeneration] Generated audio using gTTS: {output_path}")
return True
except ImportError:
logger.error("[StoryAudioGeneration] gTTS not installed. Install with: pip install gtts")
return False
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio with gTTS: {e}")
return False
def _generate_audio_pyttsx3(
self,
text: str,
output_path: Path,
rate: int = 150,
voice: Optional[str] = None
) -> bool:
"""
Generate audio using pyttsx3 (offline TTS).
Parameters:
text (str): Text to convert to speech.
output_path (Path): Path to save the audio file.
rate (int): Speech rate (default: 150).
voice (str, optional): Voice ID to use.
Returns:
bool: True if generation was successful, False otherwise.
"""
try:
import pyttsx3
# Initialize TTS engine
engine = pyttsx3.init()
# Set speech rate
engine.setProperty('rate', rate)
# Set voice if provided
if voice:
voices = engine.getProperty('voices')
for v in voices:
if voice in v.id:
engine.setProperty('voice', v.id)
break
# Generate speech and save to file
engine.save_to_file(text, str(output_path))
engine.runAndWait()
logger.info(f"[StoryAudioGeneration] Generated audio using pyttsx3: {output_path}")
return True
except ImportError:
logger.error("[StoryAudioGeneration] pyttsx3 not installed. Install with: pip install pyttsx3")
return False
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio with pyttsx3: {e}")
return False
def generate_scene_audio(
self,
scene: Dict[str, Any],
user_id: str,
provider: str = "gtts",
lang: str = "en",
slow: bool = False,
rate: int = 150
) -> Dict[str, Any]:
"""
Generate audio narration for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data with audio_narration text.
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
lang (str): Language code for TTS (default: "en").
slow (bool): Whether to speak slowly (default: False, gTTS only).
rate (int): Speech rate (default: 150, pyttsx3 only).
Returns:
Dict[str, Any]: Audio metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
audio_narration = scene.get("audio_narration", "")
if not audio_narration:
raise ValueError(f"Scene {scene_number} ({scene_title}) has no audio_narration")
try:
logger.info(f"[StoryAudioGeneration] Generating audio for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryAudioGeneration] Audio narration: {audio_narration[:100]}...")
# Generate audio filename
audio_filename = self._generate_audio_filename(scene_number, scene_title)
audio_path = self.output_dir / audio_filename
# Generate audio based on provider
success = False
if provider == "gtts":
success = self._generate_audio_gtts(
text=audio_narration,
output_path=audio_path,
lang=lang,
slow=slow
)
elif provider == "pyttsx3":
success = self._generate_audio_pyttsx3(
text=audio_narration,
output_path=audio_path,
rate=rate
)
else:
# Default to gTTS
logger.warning(f"[StoryAudioGeneration] Unknown provider '{provider}', using gTTS")
success = self._generate_audio_gtts(
text=audio_narration,
output_path=audio_path,
lang=lang,
slow=slow
)
if not success or not audio_path.exists():
raise RuntimeError(f"Failed to generate audio file: {audio_path}")
# Get file size
file_size = audio_path.stat().st_size
logger.info(f"[StoryAudioGeneration] Saved audio to: {audio_path} ({file_size} bytes)")
# Return audio metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"audio_path": str(audio_path),
"audio_filename": audio_filename,
"audio_url": f"/api/story/audio/{audio_filename}", # API endpoint to serve audio
"provider": provider,
"file_size": file_size,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate audio for scene {scene_number}: {str(e)}") from e
def generate_scene_audio_list(
self,
scenes: List[Dict[str, Any]],
user_id: str,
provider: str = "gtts",
lang: str = "en",
slow: bool = False,
rate: int = 150,
progress_callback: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""
Generate audio narration for multiple story scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data with audio_narration text.
user_id (str): Clerk user ID for subscription checking.
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
lang (str): Language code for TTS (default: "en").
slow (bool): Whether to speak slowly (default: False, gTTS only).
rate (int): Speech rate (default: 150, pyttsx3 only).
progress_callback (callable, optional): Callback function for progress updates.
Returns:
List[Dict[str, Any]]: List of audio metadata for each scene.
"""
if not scenes:
raise ValueError("No scenes provided for audio generation")
logger.info(f"[StoryAudioGeneration] Generating audio for {len(scenes)} scenes")
audio_results = []
total_scenes = len(scenes)
for idx, scene in enumerate(scenes):
try:
# Generate audio for scene
audio_result = self.generate_scene_audio(
scene=scene,
user_id=user_id,
provider=provider,
lang=lang,
slow=slow,
rate=rate
)
audio_results.append(audio_result)
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / total_scenes) * 100
progress_callback(progress, f"Generated audio for scene {scene.get('scene_number', idx + 1)}")
logger.info(f"[StoryAudioGeneration] Generated audio {idx + 1}/{total_scenes}")
except Exception as e:
logger.error(f"[StoryAudioGeneration] Failed to generate audio for scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
# Use empty strings for required fields instead of None
audio_results.append({
"scene_number": scene.get("scene_number", idx + 1),
"scene_title": scene.get("title", "Untitled"),
"audio_filename": "",
"audio_url": "",
"provider": provider,
"file_size": 0,
"error": str(e),
})
logger.info(f"[StoryAudioGeneration] Generated {len(audio_results)} audio files out of {total_scenes} scenes")
return audio_results

View File

@@ -0,0 +1,196 @@
"""
Image Generation Service for Story Writer
Generates images for story scenes using the existing image generation service.
"""
import os
import base64
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from fastapi import HTTPException
from services.llm_providers.main_image_generation import generate_image
from services.llm_providers.image_generation import ImageGenerationResult
from utils.logger_utils import get_service_logger
logger = get_service_logger("story_writer.image_generation")
class StoryImageGenerationService:
"""Service for generating images for story scenes."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the image generation service.
Parameters:
output_dir (str, optional): Directory to save generated images.
Defaults to 'backend/story_images' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_images directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_images"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}")
def _generate_image_filename(self, scene_number: int, scene_title: str) -> str:
"""Generate a unique filename for a scene image."""
# Clean scene title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"scene_{scene_number}_{clean_title}_{unique_id}.png"
def generate_scene_image(
self,
scene: Dict[str, Any],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None
) -> Dict[str, Any]:
"""
Generate an image for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data with image_prompt.
user_id (str): Clerk user ID for subscription checking.
provider (str, optional): Image generation provider (gemini, huggingface, stability).
width (int): Image width (default: 1024).
height (int): Image height (default: 1024).
model (str, optional): Model to use for image generation.
Returns:
Dict[str, Any]: Image metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
image_prompt = scene.get("image_prompt", "")
if not image_prompt:
raise ValueError(f"Scene {scene_number} ({scene_title}) has no image_prompt")
try:
logger.info(f"[StoryImageGeneration] Generating image for scene {scene_number}: {scene_title}")
logger.debug(f"[StoryImageGeneration] Image prompt: {image_prompt[:100]}...")
# Generate image using main_image_generation service
image_options = {
"provider": provider,
"width": width,
"height": height,
"model": model,
}
result: ImageGenerationResult = generate_image(
prompt=image_prompt,
options=image_options,
user_id=user_id
)
# Save image to file
image_filename = self._generate_image_filename(scene_number, scene_title)
image_path = self.output_dir / image_filename
with open(image_path, "wb") as f:
f.write(result.image_bytes)
logger.info(f"[StoryImageGeneration] Saved image to: {image_path}")
# Return image metadata
# Use relative path for image_url (will be served via API endpoint)
return {
"scene_number": scene_number,
"scene_title": scene_title,
"image_path": str(image_path),
"image_filename": image_filename,
"image_url": f"/api/story/images/{image_filename}", # API endpoint to serve images
"width": result.width,
"height": result.height,
"provider": result.provider,
"model": result.model,
"seed": result.seed,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryImageGeneration] Error generating image for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate image for scene {scene_number}: {str(e)}") from e
def generate_scene_images(
self,
scenes: List[Dict[str, Any]],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
progress_callback: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""
Generate images for multiple story scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data with image_prompts.
user_id (str): Clerk user ID for subscription checking.
provider (str, optional): Image generation provider (gemini, huggingface, stability).
width (int): Image width (default: 1024).
height (int): Image height (default: 1024).
model (str, optional): Model to use for image generation.
progress_callback (callable, optional): Callback function for progress updates.
Returns:
List[Dict[str, Any]]: List of image metadata for each scene.
"""
if not scenes:
raise ValueError("No scenes provided for image generation")
logger.info(f"[StoryImageGeneration] Generating images for {len(scenes)} scenes")
image_results = []
total_scenes = len(scenes)
for idx, scene in enumerate(scenes):
try:
# Generate image for scene
image_result = self.generate_scene_image(
scene=scene,
user_id=user_id,
provider=provider,
width=width,
height=height,
model=model
)
image_results.append(image_result)
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / total_scenes) * 100
progress_callback(progress, f"Generated image for scene {scene.get('scene_number', idx + 1)}")
logger.info(f"[StoryImageGeneration] Generated image {idx + 1}/{total_scenes}")
except Exception as e:
logger.error(f"[StoryImageGeneration] Failed to generate image for scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
image_results.append({
"scene_number": scene.get("scene_number", idx + 1),
"scene_title": scene.get("title", "Untitled"),
"error": str(e),
"image_path": None,
"image_url": None,
})
logger.info(f"[StoryImageGeneration] Generated {len(image_results)} images out of {total_scenes} scenes")
return image_results

View File

@@ -0,0 +1,14 @@
"""Story Writer service component helpers."""
from .base import StoryServiceBase
from .setup import StorySetupMixin
from .outline import StoryOutlineMixin
from .story_content import StoryContentMixin
__all__ = [
"StoryServiceBase",
"StorySetupMixin",
"StoryOutlineMixin",
"StoryContentMixin",
]

View File

@@ -0,0 +1,332 @@
"""Core shared functionality for Story Writer service components."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
class StoryServiceBase:
"""Base class providing shared helpers for story writer operations."""
guidelines: str = """\
Writing Guidelines:
Delve deeper. Lose yourself in the world you're building. Unleash vivid
descriptions to paint the scenes in your reader's mind.
Develop your characters — let their motivations, fears, and complexities unfold naturally.
Weave in the threads of your outline, but don't feel constrained by it.
Allow your story to surprise you as you write. Use rich imagery, sensory details, and
evocative language to bring the setting, characters, and events to life.
Introduce elements subtly that can blossom into complex subplots, relationships,
or worldbuilding details later in the story.
Keep things intriguing but not fully resolved.
Avoid boxing the story into a corner too early.
Plant the seeds of subplots or potential character arc shifts that can be expanded later.
IMPORTANT: Respect the story length target. Write with appropriate detail and pacing
to reach the target word count, but do NOT exceed it. Once you've reached the target
length and provided satisfying closure, conclude the story by writing IAMDONE.
"""
# ------------------------------------------------------------------ #
# LLM Utilities
# ------------------------------------------------------------------ #
def generate_with_retry(
self,
prompt: str,
*,
system_prompt: Optional[str] = None,
user_id: Optional[str] = None,
) -> str:
"""Generate content using llm_text_gen with retry handling and subscription support."""
if not user_id:
raise RuntimeError("user_id is required for subscription checking")
try:
return llm_text_gen(prompt=prompt, system_prompt=system_prompt, user_id=user_id)
except HTTPException:
raise
except Exception as exc:
logger.error(f"Error generating content: {exc}")
raise RuntimeError(f"Failed to generate content: {exc}") from exc
# ------------------------------------------------------------------ #
# Prompt helpers
# ------------------------------------------------------------------ #
def build_persona_prompt(
self,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
) -> str:
"""Build the persona prompt with all story parameters."""
return f"""{persona}
**STORY SETUP CONTEXT:**
**Setting:**
{story_setting}
- Use this specific setting throughout the story
- Incorporate setting details naturally into scenes and descriptions
- Ensure the setting is clearly established and consistent
**Characters:**
{character_input}
- Use these specific characters in the story
- Develop these characters according to their descriptions
- Maintain character consistency across all scenes
- Create character arcs that align with the plot elements
**Plot Elements:**
{plot_elements}
- Incorporate these plot elements into the story structure
- Address each plot element in relevant scenes
- Build connections between plot elements logically
- Ensure the ending addresses the main plot elements
**Writing Style:**
{writing_style}
- This writing style should be reflected in EVERY aspect of the story
- The language, sentence structure, and narrative approach must match this style exactly
- If this is a custom or combined style, interpret it in the context of the audience age group
- Adapt the style's complexity to match {audience_age_group}
**Story Tone:**
{story_tone}
- This tone must be maintained consistently throughout the entire story
- The emotional atmosphere, mood, and overall feeling must match this tone
- If this is a custom or combined tone, interpret it age-appropriately for {audience_age_group}
- Ensure the tone is suitable for {content_rating} content rating
**Narrative Point of View:**
{narrative_pov}
- Use this perspective consistently throughout the story
- Maintain the chosen perspective in all narration
- Apply the perspective appropriately for {audience_age_group}
**Target Audience:**
{audience_age_group}
- ALL content must be age-appropriate for this audience
- Language complexity, vocabulary, sentence length, and themes must match this age group
- Concepts must be understandable and relatable to this audience
- Adjust all story elements (style, tone, plot) to be appropriate for this age group
**Content Rating:**
{content_rating}
- All content must stay within these content boundaries
- Themes, language, and subject matter must respect this rating
- Ensure the writing style and tone are compatible with this rating
**Ending Preference:**
{ending_preference}
- The story should build toward this type of ending
- All plot development should lead naturally to this ending style
- Create expectations that align with this ending preference
- Ensure the ending is appropriate for {audience_age_group} and {content_rating}
**CRITICAL INSTRUCTIONS:**
- Use ALL of the above story setup parameters to guide your writing
- The writing style, tone, narrative POV, audience age group, and content rating are NOT optional - they are REQUIRED constraints
- Every word, sentence, and description must align with these parameters
- When parameters interact (e.g., style + age group, tone + content rating), ensure they work together harmoniously
- Tailor the language complexity, vocabulary, and concepts to the specified audience age group
- Maintain consistency with the specified writing style and tone throughout
- Ensure all content is appropriate for the specified content rating
- Build the narrative toward the specified ending preference
- Use the setting, characters, and plot elements provided to create a coherent, engaging story
Make sure the story is engaging, well-crafted, and perfectly tailored to ALL of the specified parameters above.
"""
def _get_parameter_interaction_guidance(
self,
writing_style: str,
story_tone: str,
audience_age_group: str,
content_rating: str,
) -> str:
"""Generate guidance for interpreting custom/combined parameter values and their interactions."""
guidance = "**PARAMETER INTERACTION GUIDANCE:**\n\n"
style_words = writing_style.lower().split()
if len(style_words) > 1:
guidance += f"**Writing Style Analysis:** The style '{writing_style}' appears to combine multiple approaches:\n"
for word in style_words:
guidance += f"- '{word.title()}': Interpret this aspect in the context of {audience_age_group}\n"
guidance += (
"Combine all aspects naturally. For example, if 'Educational Playful':\n"
f" → Use playful, engaging language to teach concepts naturally\n"
f" → Make learning fun and interactive for {audience_age_group}\n"
" → Combine educational content with fun, magical elements\n\n"
)
else:
guidance += f"**Writing Style:** '{writing_style}'\n"
guidance += f"- Interpret this style appropriately for {audience_age_group}\n"
guidance += "- Adapt the style's complexity to match the audience's reading level\n\n"
tone_words = story_tone.lower().split()
if len(tone_words) > 1:
guidance += f"**Story Tone Analysis:** The tone '{story_tone}' combines multiple emotional qualities:\n"
for word in tone_words:
guidance += f"- '{word.title()}': Express this emotion in an age-appropriate way for {audience_age_group}\n"
guidance += (
"Blend these emotions throughout the story. For example, if 'Educational Whimsical':\n"
" → Use whimsical, playful language to convey educational concepts\n"
" → Make the tone both informative and magical\n"
f" → Combine wonder and learning in an age-appropriate way for {audience_age_group}\n\n"
)
else:
guidance += f"**Story Tone:** '{story_tone}'\n"
guidance += f"- Interpret this tone age-appropriately for {audience_age_group}\n"
guidance += f"- Ensure the tone is suitable for {content_rating} content rating\n\n"
guidance += "**PARAMETER INTERACTION EXAMPLES:**\n\n"
if "Children (5-12)" in audience_age_group:
guidance += f"- When writing_style is '{writing_style}' AND audience_age_group is 'Children (5-12)':\n"
guidance += " → Simplify the style's complexity while maintaining its essence\n"
guidance += " → Use age-appropriate vocabulary and sentence structure\n"
guidance += " → Make the style engaging and accessible for children\n\n"
if "Children (5-12)" in audience_age_group and "dark" in story_tone.lower():
guidance += f"- When story_tone is '{story_tone}' AND audience_age_group is 'Children (5-12)':\n"
guidance += " → Interpret 'dark' as mysterious and adventurous, not scary or frightening\n"
guidance += " → Use shadows, secrets, and puzzles rather than fear or horror\n"
guidance += " → Maintain a sense of wonder and excitement\n"
guidance += " → Keep it thrilling but age-appropriate\n\n"
guidance += f"- When writing_style is '{writing_style}' AND story_tone is '{story_tone}':\n"
guidance += " → Combine the style and tone naturally\n"
guidance += " → Use the style to express the tone effectively\n"
guidance += f" → Ensure both work together harmoniously for {audience_age_group}\n\n"
guidance += f"- When content_rating is '{content_rating}':\n"
guidance += " → Ensure the writing style and tone respect these content boundaries\n"
guidance += " → Adjust language, themes, and subject matter to fit the rating\n"
guidance += f" → Maintain age-appropriateness for {audience_age_group}\n\n"
guidance += "**PARAMETER CONFLICT RESOLUTION:**\n"
guidance += "If parameters seem to conflict, prioritize in this order:\n"
guidance += "1. Audience age group appropriateness (safety and comprehension) - HIGHEST PRIORITY\n"
guidance += "2. Content rating compliance (content boundaries)\n"
guidance += "3. Writing style and tone (creative expression)\n"
guidance += "4. Other parameters (narrative POV, ending preference)\n\n"
guidance += "Always ensure that ALL parameters work together to create appropriate, engaging content.\n"
return guidance
# ------------------------------------------------------------------ #
# Outline helpers shared across modules
# ------------------------------------------------------------------ #
def _format_outline_for_prompt(self, outline: Any) -> str:
"""Format outline (structured or text) for use in prompts."""
if isinstance(outline, list):
outline_text = "\n".join(
[
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
f" Description: {scene.get('description', '')}\n"
f" Key Events: {', '.join(scene.get('key_events', []))}"
for idx, scene in enumerate(outline)
]
)
return outline_text
return str(outline)
def _parse_text_outline(self, outline_prompt: str, user_id: str) -> List[Dict[str, Any]]:
"""Fallback method to parse text outline if JSON parsing fails."""
outline_text = self.generate_with_retry(outline_prompt, user_id=user_id)
lines = outline_text.strip().split("\n")
scenes: List[Dict[str, Any]] = []
current_scene: Optional[Dict[str, Any]] = None
for line in lines:
cleaned = line.strip()
if not cleaned:
continue
if cleaned[0].isdigit() or cleaned.startswith("Scene") or cleaned.startswith("Chapter"):
if current_scene:
scenes.append(current_scene)
scene_number = len(scenes) + 1
title = cleaned.replace(f"{scene_number}.", "").replace("Scene", "").replace("Chapter", "").strip()
current_scene = {
"scene_number": scene_number,
"title": title or f"Scene {scene_number}",
"description": "",
"image_prompt": f"A scene from the story: {title}",
"audio_narration": "",
"character_descriptions": [],
"key_events": [],
}
continue
if current_scene:
if current_scene["description"]:
current_scene["description"] += " " + cleaned
else:
current_scene["description"] = cleaned
if current_scene["image_prompt"].startswith("A scene from the story"):
current_scene["image_prompt"] = f"A detailed visual representation of: {current_scene['description'][:200]}"
if not current_scene["audio_narration"]:
current_scene["audio_narration"] = (
current_scene["description"][:150] + "..."
if len(current_scene["description"]) > 150
else current_scene["description"]
)
if current_scene:
scenes.append(current_scene)
if not scenes:
scenes.append(
{
"scene_number": 1,
"title": "Story Outline",
"description": outline_text.strip(),
"image_prompt": f"A scene from the story: {outline_text[:200]}",
"audio_narration": outline_text[:150] + "..." if len(outline_text) > 150 else outline_text,
"character_descriptions": [],
"key_events": [],
}
)
logger.info(f"[StoryWriter] Parsed {len(scenes)} scenes from text outline")
return scenes
def _get_story_length_guidance(self, story_length: str) -> tuple[int, int]:
"""Return word count guidance based on story length."""
story_length_lower = story_length.lower()
if "short" in story_length_lower or "1000" in story_length_lower:
return (1000, 0)
if "long" in story_length_lower or "10000" in story_length_lower:
return (3000, 2500)
return (2000, 1500)
@staticmethod
def load_json_response(response_text: Any) -> Dict[str, Any]:
"""Normalize responses from llm_text_gen (dict or json string)."""
if isinstance(response_text, dict):
return response_text
if isinstance(response_text, str):
return json.loads(response_text)
raise ValueError(f"Unexpected response type: {type(response_text)}")

View File

@@ -0,0 +1,171 @@
"""Story outline generation helpers."""
from __future__ import annotations
import json
from typing import Any, Dict
from fastapi import HTTPException
from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
from .base import StoryServiceBase
class StoryOutlineMixin(StoryServiceBase):
"""Provides outline generation behaviour."""
def _get_outline_schema(self) -> Dict[str, Any]:
"""Return JSON schema for structured story outlines."""
return {
"type": "object",
"properties": {
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"scene_number": {"type": "integer"},
"title": {"type": "string"},
"description": {"type": "string"},
"image_prompt": {"type": "string"},
"audio_narration": {"type": "string"},
"character_descriptions": {"type": "array", "items": {"type": "string"}},
"key_events": {"type": "array", "items": {"type": "string"}},
},
"required": ["scene_number", "title", "description", "image_prompt", "audio_narration"],
},
}
},
"required": ["scenes"],
}
def generate_outline(
self,
*,
premise: str,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
use_structured_output: bool = True,
) -> Any:
"""Generate a story outline with optional structured JSON output."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
parameter_guidance = self._get_parameter_interaction_guidance(
writing_style, story_tone, audience_age_group, content_rating
)
outline_prompt = f"""\
{persona_prompt}
**PREMISE:**
{premise}
{parameter_guidance}
**YOUR TASK:**
Create a detailed story outline with multiple scenes that brings this premise to life. The outline must perfectly align with ALL of the story setup parameters provided above.
**SCENE PROGRESSION STRUCTURE:**
**Scene 1-2 (Opening):**
- Introduce the setting ({story_setting}) and main characters ({character_input})
- Establish the {story_tone} tone from the beginning
- Set up the main conflict or adventure based on the plot elements ({plot_elements})
- Hook the audience with an engaging opening that matches {writing_style} style
- Use the {narrative_pov} perspective to establish the story world
- Create intrigue and interest appropriate for {audience_age_group}
- Respect the {content_rating} content rating from the start
**Scene 3-7 (Development):**
- Develop the plot elements ({plot_elements}) in detail
- Build character relationships and growth using the specified characters ({character_input})
- Create tension, obstacles, or challenges that advance the story
- Maintain the {writing_style} style consistently throughout
- Progress toward the {ending_preference} ending
- Explore the setting ({story_setting}) more deeply
- Ensure all content is age-appropriate for {audience_age_group}
- Maintain the {story_tone} tone while developing the plot
- Respect the {content_rating} content rating in all scenes
- Use the {narrative_pov} perspective consistently
**Final Scenes (Resolution):**
- Resolve the main conflict established in the plot elements ({plot_elements})
- Deliver the {ending_preference} ending
- Tie together all plot elements and character arcs
- Provide satisfying closure appropriate for {audience_age_group}
- Maintain the {writing_style} style and {story_tone} tone until the end
- Ensure the ending respects the {content_rating} content rating
- Use the {narrative_pov} perspective to conclude the story
**OUTLINE STRUCTURE:**
For each scene, provide:
1. **Scene Number and Title**
2. **Description** (written in {writing_style}, maintaining {story_tone}, and age-appropriate for {audience_age_group})
3. **Image Prompt** (vivid, visually descriptive, includes setting/characters, age-appropriate)
4. **Audio Narration** (2-3 sentences, engaging, maintains style/tone, suitable for narration)
5. **Character Descriptions** (for characters appearing in the scene)
6. **Key Events** (bullet list of important happenings)
**CONTEXT INTEGRATION REQUIREMENTS:**
- Ensure every scene reflects the setting ({story_setting})
- Keep characters consistent with ({character_input})
- Integrate plot elements ({plot_elements}) logically
- Maintain persona voice ({persona})
- Respect audience age group ({audience_age_group}) and content rating ({content_rating})
Before finalizing, verify that every scene adheres to the writing style, tone, age appropriateness, content rating, and narrative POV. Create 5-10 scenes that tell a complete, engaging story with clear progression and satisfying resolution.
"""
try:
if use_structured_output:
outline_schema = self._get_outline_schema()
try:
response = self.load_json_response(
llm_text_gen(prompt=outline_prompt, json_struct=outline_schema, user_id=user_id)
)
scenes = response.get("scenes", [])
if scenes:
logger.info(f"[StoryWriter] Generated {len(scenes)} structured scenes for user {user_id}")
logger.info(
"[StoryWriter] Outline generated with parameters: "
f"audience={audience_age_group}, style={writing_style}, tone={story_tone}"
)
return scenes
logger.warning("[StoryWriter] No scenes found in structured output, falling back to text parsing")
raise ValueError("No scenes found in structured output")
except (json.JSONDecodeError, ValueError, KeyError) as exc:
logger.warning(
f"[StoryWriter] Failed to parse structured JSON outline ({exc}), falling back to text parsing"
)
return self._parse_text_outline(outline_prompt, user_id)
outline = self.generate_with_retry(outline_prompt, user_id=user_id)
return outline.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Outline Generation Error: {exc}")
raise RuntimeError(f"Failed to generate outline: {exc}") from exc

View File

@@ -0,0 +1,273 @@
"""Story setup generation helpers."""
from __future__ import annotations
import json
from typing import Any, Dict, List
from fastapi import HTTPException
from loguru import logger
from .base import StoryServiceBase
class StorySetupMixin(StoryServiceBase):
"""Provides story setup generation behaviour."""
def generate_premise(
self,
*,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
) -> str:
"""Generate a story premise."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
parameter_guidance = self._get_parameter_interaction_guidance(
writing_style, story_tone, audience_age_group, content_rating
)
premise_prompt = f"""\
{persona_prompt}
{parameter_guidance}
**TASK: Write a SINGLE, BRIEF premise sentence (1-2 sentences maximum, approximately 20-40 words) for this story.**
The premise MUST:
1. Be written in the specified {writing_style} writing style
- Interpret and apply this style appropriately for {audience_age_group}
- Match the language complexity, sentence structure, and narrative approach of this style
2. Match the {story_tone} story tone exactly
- Express the emotional atmosphere and mood indicated by this tone
- Ensure the tone is age-appropriate for {audience_age_group}
3. Be appropriate for {audience_age_group} with {content_rating} content rating
- Use language complexity that matches this audience's reading level
- Use vocabulary that is understandable to this age group
- Present concepts that are relatable and explainable to this audience
- Respect the {content_rating} content rating boundaries
4. Briefly describe the story elements:
- Setting: {story_setting}
- Characters: {character_input}
- Main plot: {plot_elements}
5. Be clear, engaging, and set up the story without telling the whole story
6. Be written from the {narrative_pov} point of view
7. Set up for a {ending_preference} ending
**CRITICAL: This is a PREMISE, not the full story.**
- Keep it to 1-2 sentences maximum (approximately 20-40 words)
- Do NOT write the full story or multiple paragraphs
- Do NOT reveal the resolution or ending
- Focus on the setup: who, where, and what the main challenge/adventure is
- Use ALL story setup parameters to guide your language and content choices
- Tailor every word to the target audience ({audience_age_group}) and writing style ({writing_style})
Write ONLY the premise sentence(s). Do not write anything else.
"""
try:
premise = self.generate_with_retry(premise_prompt, user_id=user_id).strip()
sentences = premise.split(". ")
if len(sentences) > 2:
premise = ". ".join(sentences[:2])
if not premise.endswith("."):
premise += "."
return premise
except HTTPException:
raise
except Exception as exc:
logger.error(f"Premise Generation Error: {exc}")
raise RuntimeError(f"Failed to generate premise: {exc}") from exc
# ------------------------------------------------------------------ #
# Setup options
# ------------------------------------------------------------------ #
def _build_setup_schema(self) -> Dict[str, Any]:
"""Return JSON schema for structured setup options."""
return {
"type": "object",
"properties": {
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"persona": {"type": "string"},
"story_setting": {"type": "string"},
"character_input": {"type": "string"},
"plot_elements": {"type": "string"},
"writing_style": {"type": "string"},
"story_tone": {"type": "string"},
"narrative_pov": {"type": "string"},
"audience_age_group": {"type": "string"},
"content_rating": {"type": "string"},
"ending_preference": {"type": "string"},
"story_length": {"type": "string"},
"premise": {"type": "string"},
"reasoning": {"type": "string"},
},
"required": [
"persona",
"story_setting",
"character_input",
"plot_elements",
"writing_style",
"story_tone",
"narrative_pov",
"audience_age_group",
"content_rating",
"ending_preference",
"story_length",
"premise",
"reasoning",
],
},
"minItems": 3,
"maxItems": 3,
}
},
"required": ["options"],
}
def generate_story_setup_options(
self,
*,
story_idea: str,
user_id: str,
) -> List[Dict[str, Any]]:
"""Generate 3 story setup options from a user's story idea."""
suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative']
suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous']
suggested_narrative_povs = ['First Person', 'Third Person Limited', 'Third Person Omniscient']
suggested_audience_age_groups = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages']
suggested_content_ratings = ['G', 'PG', 'PG-13', 'R']
suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet']
setup_prompt = f"""\
You are an expert story writer and creative writing assistant. A user has provided the following story idea or information:
{story_idea}
Based on this story idea, generate exactly 3 different, well-thought-out story setup options. Each option should be CREATIVE, PERSONALIZED, and perfectly tailored to the user's specific story idea.
**CRITICAL - Creative Freedom:**
- You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea
- Do NOT limit yourself to predefined options - create custom, creative values that perfectly match the story concept
- For example, if the user wants "a story about how stars are made for a 5-year-old", you might create:
- Writing Style: "Educational Playful" or "Simple Scientific" (not just "Casual" or "Poetic")
- Story Tone: "Wonder-filled" or "Curious Discovery" (not just "Whimsical" or "Uplifting")
- Narrative POV: "Second Person (You)" or "Omniscient Narrator as Guide" (not just standard options)
- The goal is to create the PERFECT setup for THIS specific story, not to fit into generic categories
Each option should:
1. Have a unique and creative persona that fits the story idea perfectly
2. Define a compelling story setting that brings the idea to life
3. Describe interesting and engaging characters
4. Include key plot elements that drive the narrative
5. Create CUSTOM, PERSONALIZED values for writing style, story tone, narrative POV, audience age group, content rating, and ending preference that best serve the story idea
6. Select an appropriate story length: "Short (>1000 words)" for brief stories, "Medium (>5000 words)" for standard-length stories, or "Long (>10000 words)" for extended, detailed stories
7. Generate a brief story premise (1-2 sentences, approximately 20-40 words) that summarizes the story concept
8. Provide a brief reasoning (2-3 sentences) explaining why this setup works well for the story idea
**IMPORTANT - Premise Requirements:**
- The premise MUST be age-appropriate for the selected audience_age_group
- For Children (5-12): Use simple, everyday words. Avoid complex vocabulary like "nebular", "ionized", "cosmic", "stellar", "melancholic", "bittersweet"
- The premise MUST match the selected writing_style (e.g., if custom style is "Educational Playful", use playful educational language)
- The premise MUST match the selected story_tone (e.g., if custom tone is "Wonder-filled", create a sense of wonder)
- Keep the premise to 1-2 sentences maximum
- Focus on who, where, and what the main challenge/adventure is
**Suggested Options (for reference only - feel free to create better custom values):**
- Writing Styles (suggestions): {', '.join(suggested_writing_styles)}
- Story Tones (suggestions): {', '.join(suggested_story_tones)}
- Narrative POVs (suggestions): {', '.join(suggested_narrative_povs)}
- Audience Age Groups (suggestions): {', '.join(suggested_audience_age_groups)}
- Content Ratings (suggestions): {', '.join(suggested_content_ratings)}
- Ending Preferences (suggestions): {', '.join(suggested_ending_preferences)}
- Story Lengths: "Short (>1000 words)", "Medium (>5000 words)", "Long (>10000 words)"
**Remember:** These are ONLY suggestions. If a custom value better serves the story idea, CREATE IT!
Return exactly 3 options as a JSON array. Each option must include a "premise" field with the story premise.
"""
setup_schema = self._build_setup_schema()
try:
logger.info(f"[StoryWriter] Generating story setup options for user {user_id}")
response = self.load_json_response(
llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id)
)
options = response.get("options", [])
if len(options) != 3:
logger.warning(f"[StoryWriter] Expected 3 options but got {len(options)}, correcting count")
if len(options) < 3:
raise ValueError(f"Expected 3 options but got {len(options)}")
options = options[:3]
for idx, option in enumerate(options):
if not option.get("premise") or not option.get("premise", "").strip():
logger.info(f"[StoryWriter] Generating premise for option {idx + 1}")
try:
option["premise"] = self.generate_premise(
persona=option.get("persona", ""),
story_setting=option.get("story_setting", ""),
character_input=option.get("character_input", ""),
plot_elements=option.get("plot_elements", ""),
writing_style=option.get("writing_style", "Narrative"),
story_tone=option.get("story_tone", "Adventurous"),
narrative_pov=option.get("narrative_pov", "Third Person Limited"),
audience_age_group=option.get("audience_age_group", "All Ages"),
content_rating=option.get("content_rating", "G"),
ending_preference=option.get("ending_preference", "Happy"),
user_id=user_id,
)
except Exception as exc: # pragma: no cover - fallback clause
logger.warning(f"[StoryWriter] Failed to generate premise for option {idx + 1}: {exc}")
option["premise"] = (
f"A {option.get('story_setting', 'story')} story featuring "
f"{option.get('character_input', 'characters')}."
)
else:
premise = option["premise"].strip()
sentences = premise.split(". ")
if len(sentences) > 2:
premise = ". ".join(sentences[:2])
if not premise.endswith("."):
premise += "."
option["premise"] = premise
logger.info(f"[StoryWriter] Generated {len(options)} story setup options with premises for user {user_id}")
return options
except HTTPException:
raise
except json.JSONDecodeError as exc:
logger.error(f"[StoryWriter] Failed to parse JSON response for story setup: {exc}")
raise RuntimeError(f"Failed to parse story setup options: {exc}") from exc
except Exception as exc:
logger.error(f"[StoryWriter] Error generating story setup options: {exc}")
raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc

View File

@@ -0,0 +1,428 @@
"""Story content generation helpers."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from loguru import logger
from services.story_writer.image_generation_service import StoryImageGenerationService
from .base import StoryServiceBase
from .outline import StoryOutlineMixin
class StoryContentMixin(StoryOutlineMixin):
"""Provides story drafting and continuation behaviour."""
# ------------------------------------------------------------------ #
# Story start
# ------------------------------------------------------------------ #
def generate_story_start(
self,
*,
premise: str,
outline: Any,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
story_length: str = "Medium",
user_id: str,
) -> str:
"""Generate the starting section (or full short story)."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
outline_text = self._format_outline_for_prompt(outline)
story_length_lower = story_length.lower()
is_short_story = "short" in story_length_lower or "1000" in story_length_lower
if is_short_story:
logger.info(f"[StoryWriter] Generating complete short story (~1000 words) in single call for user {user_id}")
short_story_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
**YOUR TASK:**
Write the COMPLETE story from beginning to end. This is a SHORT story, so you need to write the entire narrative in a single response.
**STORY LENGTH TARGET:**
- Target: Approximately 1000 words (900-1100 words acceptable)
- This is a SHORT story, so be concise but complete
- Cover all key scenes from your outline
- Provide a satisfying conclusion that addresses all plot elements
- Ensure the story makes sense as a complete narrative
**STORY STRUCTURE:**
1. **Opening**: Establish setting, characters, and initial situation
2. **Development**: Develop the plot, introduce conflicts, build tension
3. **Climax**: Reach the story's peak moment
4. **Resolution**: Resolve conflicts and provide closure
**IMPORTANT INSTRUCTIONS:**
- Write the COMPLETE story in this single response
- Aim for approximately 1000 words (900-1100 words)
- Ensure the story is complete and makes sense as a standalone narrative
- Include all essential elements from your outline
- Provide a satisfying ending that matches the ending preference: {ending_preference}
- Do NOT leave the story incomplete - this is the only generation call for short stories
- Once you've finished the complete story, conclude naturally - do NOT write IAMDONE
**WRITING STYLE:**
{self.guidelines}
**REMEMBER:**
- This is a SHORT story - be concise but complete
- Write the ENTIRE story in this response
- Aim for ~1000 words
- Ensure the story is complete and satisfying
- Cover all key elements from your outline
"""
try:
complete_story = self.generate_with_retry(short_story_prompt, user_id=user_id)
complete_story = complete_story.replace("IAMDONE", "").strip()
logger.info(
f"[StoryWriter] Generated complete short story ({len(complete_story.split())} words) for user {user_id}"
)
return complete_story
except HTTPException:
raise
except Exception as exc:
logger.error(f"Short Story Generation Error: {exc}")
raise RuntimeError(f"Failed to generate short story: {exc}") from exc
initial_word_count, _ = self._get_story_length_guidance(story_length)
starting_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
First, silently review the outline and the premise. Consider how to start the story.
Start to write the very beginning of the story. You are not expected to finish
the whole story now. Your writing should be detailed enough that you are only
scratching the surface of the first bullet of your outline. Try to write AT
MINIMUM {initial_word_count} WORDS.
**STORY LENGTH TARGET:**
This story is targeted to be {story_length}. Write with appropriate detail and pacing
to reach this target length across the entire story. For this initial section, focus
on establishing the setting, characters, and beginning of the plot in {initial_word_count} words.
{self.guidelines}
"""
try:
starting_draft = self.generate_with_retry(starting_prompt, user_id=user_id)
return starting_draft.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Story Start Generation Error: {exc}")
raise RuntimeError(f"Failed to generate story start: {exc}") from exc
# ------------------------------------------------------------------ #
# Continuation
# ------------------------------------------------------------------ #
def continue_story(
self,
*,
premise: str,
outline: Any,
story_text: str,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
story_length: str = "Medium",
user_id: str,
) -> str:
"""Continue writing the story."""
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
ending_preference,
)
outline_text = self._format_outline_for_prompt(outline)
_, continuation_word_count = self._get_story_length_guidance(story_length)
current_word_count = len(story_text.split()) if story_text else 0
story_length_lower = story_length.lower()
if "short" in story_length_lower or "1000" in story_length_lower:
# Safety check: short stories shouldn't reach here
return "IAMDONE"
if "long" in story_length_lower or "10000" in story_length_lower:
target_total_words = 10000
else:
target_total_words = 4500
buffer_target = int(target_total_words * 1.05)
if current_word_count >= buffer_target:
logger.info(
f"[StoryWriter] Word count ({current_word_count}) at or past buffer target ({buffer_target}). Story is complete."
)
return "IAMDONE"
if current_word_count >= target_total_words and (current_word_count - target_total_words) < 50:
logger.info(
f"[StoryWriter] Word count ({current_word_count}) is very close to target ({target_total_words}). Story is complete."
)
return "IAMDONE"
remaining_words = max(0, buffer_target - current_word_count)
if remaining_words < 50:
logger.info(f"[StoryWriter] Remaining words ({remaining_words}) are minimal. Story is complete.")
return "IAMDONE"
continuation_prompt = f"""\
{persona_prompt}
You have a gripping premise in mind:
{premise}
Your imagination has crafted a rich narrative outline:
{outline_text}
You've begun to immerse yourself in this world, and the words are flowing.
Here's what you've written so far:
{story_text}
=====
First, silently review the outline and story so far. Identify what the single
next part of your outline you should write.
Your task is to continue where you left off and write the next part of the story.
You are not expected to finish the whole story now. Your writing should be
detailed enough that you are only scratching the surface of the next part of
your outline. Try to write AT MINIMUM {continuation_word_count} WORDS.
**STORY LENGTH TARGET:**
This story is targeted to be {story_length} (target: {target_total_words} words total, with 5% buffer allowed).
You have written approximately {current_word_count} words so far, leaving approximately
{remaining_words} words remaining.
**CRITICAL INSTRUCTIONS - READ CAREFULLY:**
1. Write the next section with appropriate detail, aiming for approximately {min(continuation_word_count, remaining_words)} words.
2. **STOP CONDITION:** If after writing this continuation, the total word count will reach or exceed {target_total_words} words, you MUST conclude the story immediately by writing IAMDONE.
3. The story should reach a natural conclusion that addresses all plot elements and provides satisfying closure.
4. Once you've written IAMDONE, do NOT write any more content - stop immediately.
**WORD COUNT LIMIT:**
- Target: {target_total_words} words total (with 5% buffer: {int(target_total_words * 1.05)} words maximum)
- Current word count: {current_word_count} words
- Remaining words: {remaining_words} words
- **CRITICAL: If your continuation would bring the total to {target_total_words} words or more, conclude the story NOW and write IAMDONE.**
- **Do NOT exceed {int(target_total_words * 1.05)} words. This is a hard limit.**
- **Ensure the story is complete and makes sense when you write IAMDONE.**
{self.guidelines}
"""
try:
continuation = self.generate_with_retry(continuation_prompt, user_id=user_id)
return continuation.strip()
except HTTPException:
raise
except Exception as exc:
logger.error(f"Story Continuation Error: {exc}")
raise RuntimeError(f"Failed to continue story: {exc}") from exc
# ------------------------------------------------------------------ #
# Full generation orchestration
# ------------------------------------------------------------------ #
def generate_full_story(
self,
*,
persona: str,
story_setting: str,
character_input: str,
plot_elements: str,
writing_style: str,
story_tone: str,
narrative_pov: str,
audience_age_group: str,
content_rating: str,
ending_preference: str,
user_id: str,
max_iterations: int = 10,
) -> Dict[str, Any]:
"""Generate a complete story using prompt chaining."""
try:
logger.info(f"[StoryWriter] Generating premise for user {user_id}")
premise = self.generate_premise(
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not premise:
raise RuntimeError("Failed to generate premise")
logger.info(f"[StoryWriter] Generating outline for user {user_id}")
outline = self.generate_outline(
premise=premise,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not outline:
raise RuntimeError("Failed to generate outline")
logger.info(f"[StoryWriter] Generating story start for user {user_id}")
draft = self.generate_story_start(
premise=premise,
outline=outline,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if not draft:
raise RuntimeError("Failed to generate story start")
iteration = 0
while "IAMDONE" not in draft and iteration < max_iterations:
iteration += 1
logger.info(f"[StoryWriter] Continuation iteration {iteration}/{max_iterations}")
continuation = self.continue_story(
premise=premise,
outline=outline,
story_text=draft,
persona=persona,
story_setting=story_setting,
character_input=character_input,
plot_elements=plot_elements,
writing_style=writing_style,
story_tone=story_tone,
narrative_pov=narrative_pov,
audience_age_group=audience_age_group,
content_rating=content_rating,
ending_preference=ending_preference,
user_id=user_id,
)
if continuation:
draft += "\n\n" + continuation
else:
logger.warning(f"[StoryWriter] Empty continuation at iteration {iteration}")
break
final_story = draft.replace("IAMDONE", "").strip()
outline_response = outline
if isinstance(outline, list):
outline_response = "\n".join(
[
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
f" {scene.get('description', '')}"
for idx, scene in enumerate(outline)
]
)
return {
"premise": premise,
"outline": str(outline_response),
"story": final_story,
"iterations": iteration,
"is_complete": "IAMDONE" in draft or iteration >= max_iterations,
}
except Exception as exc:
logger.error(f"[StoryWriter] Error generating full story: {exc}")
raise RuntimeError(f"Failed to generate full story: {exc}") from exc
# ------------------------------------------------------------------ #
# Multimedia helpers
# ------------------------------------------------------------------ #
def generate_scene_images(
self,
*,
scenes: List[Dict[str, Any]],
user_id: str,
provider: Optional[str] = None,
width: int = 1024,
height: int = 1024,
model: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Generate images for story scenes."""
image_service = StoryImageGenerationService()
return image_service.generate_scene_images(
scenes=scenes, user_id=user_id, provider=provider, width=width, height=height, model=model
)

View File

@@ -0,0 +1,30 @@
"""
Story Writer Service
Core service for generating stories using prompt chaining approach.
Migrated from ToBeMigrated/ai_writers/ai_story_writer/ai_story_generator.py
"""
from typing import Dict, Any, Optional, List
from loguru import logger
from fastapi import HTTPException
import json
from services.llm_providers.main_text_generation import llm_text_gen
from services.story_writer.service_components import (
StoryContentMixin,
StoryOutlineMixin,
StoryServiceBase,
StorySetupMixin,
)
class StoryWriterService(
StoryContentMixin,
StorySetupMixin,
StoryOutlineMixin,
StoryServiceBase,
):
"""Facade class combining story writer behaviours via modular mixins."""
__slots__ = ()

View File

@@ -0,0 +1,294 @@
"""
Video Generation Service for Story Writer
Combines images and audio into animated video clips using MoviePy.
"""
import os
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from loguru import logger
from fastapi import HTTPException
class StoryVideoGenerationService:
"""Service for generating videos from story scenes, images, and audio."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the video generation service.
Parameters:
output_dir (str, optional): Directory to save generated videos.
Defaults to 'backend/story_videos' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_videos directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_videos"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}")
def _generate_video_filename(self, story_title: str = "story") -> str:
"""Generate a unique filename for a story video."""
# Clean story title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in story_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"story_{clean_title}_{unique_id}.mp4"
def generate_scene_video(
self,
scene: Dict[str, Any],
image_path: str,
audio_path: str,
user_id: str,
duration: Optional[float] = None,
fps: int = 24
) -> Dict[str, Any]:
"""
Generate a video clip for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data.
image_path (str): Path to the scene image file.
audio_path (str): Path to the scene audio file.
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
duration (float, optional): Video duration in seconds. If None, uses audio duration.
fps (int): Frames per second for video (default: 24).
Returns:
Dict[str, Any]: Video metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
try:
logger.info(f"[StoryVideoGeneration] Generating video for scene {scene_number}: {scene_title}")
# Import MoviePy
try:
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
except ImportError:
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
# Load image and audio
image_file = Path(image_path)
audio_file = Path(audio_path)
if not image_file.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
if not audio_file.exists():
raise FileNotFoundError(f"Audio not found: {audio_path}")
# Load audio to get duration
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
# Use provided duration or audio duration
video_duration = duration if duration is not None else audio_duration
# Create image clip
image_clip = ImageClip(str(image_file)).set_duration(video_duration)
image_clip = image_clip.set_fps(fps)
# Set audio to image clip
video_clip = image_clip.set_audio(audio_clip)
# Generate video filename
video_filename = f"scene_{scene_number}_{scene_title.replace(' ', '_').replace('/', '_')[:50]}_{uuid.uuid4().hex[:8]}.mp4"
video_path = self.output_dir / video_filename
# Write video file
video_clip.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Clean up clips
video_clip.close()
audio_clip.close()
image_clip.close()
# Get file size
file_size = video_path.stat().st_size
logger.info(f"[StoryVideoGeneration] Saved video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": video_duration,
"fps": fps,
"file_size": file_size,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating video for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate video for scene {scene_number}: {str(e)}") from e
def generate_story_video(
self,
scenes: List[Dict[str, Any]],
image_paths: List[str],
audio_paths: List[str],
user_id: str,
story_title: str = "Story",
fps: int = 24,
transition_duration: float = 0.5,
progress_callback: Optional[callable] = None
) -> Dict[str, Any]:
"""
Generate a complete story video from multiple scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data.
image_paths (List[str]): List of image file paths for each scene.
audio_paths (List[str]): List of audio file paths for each scene.
user_id (str): Clerk user ID for subscription checking.
story_title (str): Title of the story (default: "Story").
fps (int): Frames per second for video (default: 24).
transition_duration (float): Duration of transitions between scenes in seconds (default: 0.5).
progress_callback (callable, optional): Callback function for progress updates.
Returns:
Dict[str, Any]: Video metadata including file path, URL, and story info.
"""
if not scenes or not image_paths or not audio_paths:
raise ValueError("Scenes, image paths, and audio paths are required")
if len(scenes) != len(image_paths) or len(scenes) != len(audio_paths):
raise ValueError("Number of scenes, image paths, and audio paths must match")
try:
logger.info(f"[StoryVideoGeneration] Generating story video for {len(scenes)} scenes")
# Import MoviePy
try:
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
except ImportError:
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
scene_clips = []
total_duration = 0.0
for idx, (scene, image_path, audio_path) in enumerate(zip(scenes, image_paths, audio_paths)):
try:
scene_number = scene.get("scene_number", idx + 1)
scene_title = scene.get("title", "Untitled")
logger.info(f"[StoryVideoGeneration] Processing scene {scene_number}/{len(scenes)}: {scene_title}")
# Load image and audio
image_file = Path(image_path)
audio_file = Path(audio_path)
if not image_file.exists():
logger.warning(f"[StoryVideoGeneration] Image not found: {image_path}, skipping scene {scene_number}")
continue
if not audio_file.exists():
logger.warning(f"[StoryVideoGeneration] Audio not found: {audio_path}, skipping scene {scene_number}")
continue
# Load audio to get duration
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
# Create image clip
image_clip = ImageClip(str(image_file)).set_duration(audio_duration)
image_clip = image_clip.set_fps(fps)
# Set audio to image clip
video_clip = image_clip.set_audio(audio_clip)
scene_clips.append(video_clip)
total_duration += audio_duration
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / len(scenes)) * 90 # Reserve 10% for final composition
progress_callback(progress, f"Processed scene {scene_number}/{len(scenes)}")
logger.info(f"[StoryVideoGeneration] Processed scene {idx + 1}/{len(scenes)}")
except Exception as e:
logger.error(f"[StoryVideoGeneration] Failed to process scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
continue
if not scene_clips:
raise RuntimeError("No valid scene clips were created")
# Concatenate all scene clips
logger.info(f"[StoryVideoGeneration] Concatenating {len(scene_clips)} scene clips")
final_video = concatenate_videoclips(scene_clips, method="compose")
# Generate video filename
video_filename = self._generate_video_filename(story_title)
video_path = self.output_dir / video_filename
# Call progress callback
if progress_callback:
progress_callback(95, "Rendering final video...")
# Write video file
final_video.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Get file size
file_size = video_path.stat().st_size
# Clean up clips
final_video.close()
for clip in scene_clips:
clip.close()
# Call progress callback
if progress_callback:
progress_callback(100, "Video generation complete!")
logger.info(f"[StoryVideoGeneration] Saved story video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": total_duration,
"fps": fps,
"file_size": file_size,
"num_scenes": len(scene_clips),
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating story video: {e}")
raise RuntimeError(f"Failed to generate story video: {str(e)}") from e

View File

@@ -0,0 +1,231 @@
"""
Log Wrapping Service
Intelligently wraps API usage logs when they exceed 5000 records.
Aggregates old logs into cumulative records while preserving historical data.
"""
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from loguru import logger
from models.subscription_models import APIUsageLog, APIProvider
class LogWrappingService:
"""Service for wrapping and aggregating API usage logs."""
MAX_LOGS_PER_USER = 5000
AGGREGATION_THRESHOLD_DAYS = 30 # Aggregate logs older than 30 days
def __init__(self, db: Session):
self.db = db
def check_and_wrap_logs(self, user_id: str) -> Dict[str, Any]:
"""
Check if user has exceeded log limit and wrap if necessary.
Returns:
Dict with wrapping status and statistics
"""
try:
# Count total logs for user
total_count = self.db.query(func.count(APIUsageLog.id)).filter(
APIUsageLog.user_id == user_id
).scalar() or 0
if total_count <= self.MAX_LOGS_PER_USER:
return {
'wrapped': False,
'total_logs': total_count,
'max_logs': self.MAX_LOGS_PER_USER,
'message': f'Log count ({total_count}) is within limit ({self.MAX_LOGS_PER_USER})'
}
# Need to wrap logs - aggregate old logs
logger.info(f"[LogWrapping] User {user_id} has {total_count} logs, exceeding limit of {self.MAX_LOGS_PER_USER}. Starting wrap...")
wrap_result = self._wrap_old_logs(user_id, total_count)
return {
'wrapped': True,
'total_logs_before': total_count,
'total_logs_after': wrap_result['logs_remaining'],
'aggregated_logs': wrap_result['aggregated_count'],
'aggregated_periods': wrap_result['periods'],
'message': f'Wrapped {wrap_result["aggregated_count"]} logs into {len(wrap_result["periods"])} aggregated records'
}
except Exception as e:
logger.error(f"[LogWrapping] Error checking/wrapping logs for user {user_id}: {e}", exc_info=True)
return {
'wrapped': False,
'error': str(e),
'message': f'Error wrapping logs: {str(e)}'
}
def _wrap_old_logs(self, user_id: str, total_count: int) -> Dict[str, Any]:
"""
Aggregate old logs into cumulative records.
Strategy:
1. Keep most recent 4000 logs (detailed)
2. Aggregate logs older than 30 days or oldest logs beyond 4000
3. Create aggregated records grouped by provider and billing period
4. Delete individual logs that were aggregated
"""
try:
# Calculate how many logs to keep (4000 detailed, rest aggregated)
logs_to_keep = 4000
logs_to_aggregate = total_count - logs_to_keep
# Get cutoff date (30 days ago)
cutoff_date = datetime.utcnow() - timedelta(days=self.AGGREGATION_THRESHOLD_DAYS)
# Get logs to aggregate: oldest logs beyond the keep limit
# Order by timestamp ascending to get oldest first
# We'll keep the most recent logs_to_keep logs, aggregate the rest
logs_to_process = self.db.query(APIUsageLog).filter(
APIUsageLog.user_id == user_id
).order_by(APIUsageLog.timestamp.asc()).limit(logs_to_aggregate).all()
if not logs_to_process:
return {
'aggregated_count': 0,
'logs_remaining': total_count,
'periods': []
}
# Group logs by provider and billing period for aggregation
aggregated_data: Dict[str, Dict[str, Any]] = {}
for log in logs_to_process:
# Use provider value as key (e.g., "mistral" for huggingface)
provider_key = log.provider.value
# Special handling: if provider is MISTRAL but we want to show as huggingface
if provider_key == "mistral":
# Check if this is actually huggingface by looking at model or endpoint
# For now, we'll use "mistral" as the key but store actual provider name
provider_display = "huggingface" if "huggingface" in (log.model_used or "").lower() else "mistral"
else:
provider_display = provider_key
period_key = f"{provider_display}_{log.billing_period}"
if period_key not in aggregated_data:
aggregated_data[period_key] = {
'provider': log.provider,
'billing_period': log.billing_period,
'count': 0,
'total_tokens_input': 0,
'total_tokens_output': 0,
'total_tokens': 0,
'total_cost_input': 0.0,
'total_cost_output': 0.0,
'total_cost': 0.0,
'total_response_time': 0.0,
'success_count': 0,
'failed_count': 0,
'oldest_timestamp': log.timestamp,
'newest_timestamp': log.timestamp,
'log_ids': []
}
agg = aggregated_data[period_key]
agg['count'] += 1
agg['total_tokens_input'] += log.tokens_input or 0
agg['total_tokens_output'] += log.tokens_output or 0
agg['total_tokens'] += log.tokens_total or 0
agg['total_cost_input'] += float(log.cost_input or 0.0)
agg['total_cost_output'] += float(log.cost_output or 0.0)
agg['total_cost'] += float(log.cost_total or 0.0)
agg['total_response_time'] += float(log.response_time or 0.0)
if 200 <= log.status_code < 300:
agg['success_count'] += 1
else:
agg['failed_count'] += 1
if log.timestamp:
if log.timestamp < agg['oldest_timestamp']:
agg['oldest_timestamp'] = log.timestamp
if log.timestamp > agg['newest_timestamp']:
agg['newest_timestamp'] = log.timestamp
agg['log_ids'].append(log.id)
# Create aggregated log entries
aggregated_count = 0
periods_created = []
for period_key, agg_data in aggregated_data.items():
# Calculate averages
count = agg_data['count']
avg_response_time = agg_data['total_response_time'] / count if count > 0 else 0.0
# Create aggregated log entry
aggregated_log = APIUsageLog(
user_id=user_id,
provider=agg_data['provider'],
endpoint='[AGGREGATED]',
method='AGGREGATED',
model_used=f"[{count} calls aggregated]",
tokens_input=agg_data['total_tokens_input'],
tokens_output=agg_data['total_tokens_output'],
tokens_total=agg_data['total_tokens'],
cost_input=agg_data['total_cost_input'],
cost_output=agg_data['total_cost_output'],
cost_total=agg_data['total_cost'],
response_time=avg_response_time,
status_code=200 if agg_data['success_count'] > agg_data['failed_count'] else 500,
error_message=f"Aggregated {count} calls: {agg_data['success_count']} success, {agg_data['failed_count']} failed",
retry_count=0,
timestamp=agg_data['oldest_timestamp'], # Use oldest timestamp
billing_period=agg_data['billing_period']
)
self.db.add(aggregated_log)
periods_created.append({
'provider': agg_data['provider'].value,
'billing_period': agg_data['billing_period'],
'count': count,
'period_start': agg_data['oldest_timestamp'].isoformat() if agg_data['oldest_timestamp'] else None,
'period_end': agg_data['newest_timestamp'].isoformat() if agg_data['newest_timestamp'] else None
})
aggregated_count += count
# Delete individual logs that were aggregated
log_ids_to_delete = []
for agg_data in aggregated_data.values():
log_ids_to_delete.extend(agg_data['log_ids'])
if log_ids_to_delete:
self.db.query(APIUsageLog).filter(
APIUsageLog.id.in_(log_ids_to_delete)
).delete(synchronize_session=False)
self.db.commit()
# Get remaining log count
remaining_count = self.db.query(func.count(APIUsageLog.id)).filter(
APIUsageLog.user_id == user_id
).scalar() or 0
logger.info(
f"[LogWrapping] Wrapped {aggregated_count} logs into {len(periods_created)} aggregated records. "
f"Remaining logs: {remaining_count}"
)
return {
'aggregated_count': aggregated_count,
'logs_remaining': remaining_count,
'periods': periods_created
}
except Exception as e:
self.db.rollback()
logger.error(f"[LogWrapping] Error wrapping logs: {e}", exc_info=True)
raise

View File

@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import text
from loguru import logger
import os
from models.subscription_models import (
APIProviderPricing, SubscriptionPlan, UserSubscription,
@@ -227,6 +228,36 @@ class PricingService:
}
]
# HuggingFace/Mistral Pricing (for GPT-OSS-120B via Groq)
# Default pricing from environment variables or fallback to estimated values
# Based on Groq pricing: ~$1 per 1M input tokens, ~$3 per 1M output tokens
hf_input_cost = float(os.getenv('HUGGINGFACE_INPUT_TOKEN_COST', '0.000001')) # $1 per 1M tokens default
hf_output_cost = float(os.getenv('HUGGINGFACE_OUTPUT_TOKEN_COST', '0.000003')) # $3 per 1M tokens default
mistral_pricing = [
{
"provider": APIProvider.MISTRAL,
"model_name": "openai/gpt-oss-120b:groq",
"cost_per_input_token": hf_input_cost,
"cost_per_output_token": hf_output_cost,
"description": f"GPT-OSS-120B via HuggingFace/Groq (configurable via HUGGINGFACE_INPUT_TOKEN_COST and HUGGINGFACE_OUTPUT_TOKEN_COST env vars)"
},
{
"provider": APIProvider.MISTRAL,
"model_name": "gpt-oss-120b",
"cost_per_input_token": hf_input_cost,
"cost_per_output_token": hf_output_cost,
"description": f"GPT-OSS-120B via HuggingFace/Groq (configurable via HUGGINGFACE_INPUT_TOKEN_COST and HUGGINGFACE_OUTPUT_TOKEN_COST env vars)"
},
{
"provider": APIProvider.MISTRAL,
"model_name": "default",
"cost_per_input_token": hf_input_cost,
"cost_per_output_token": hf_output_cost,
"description": f"HuggingFace default model pricing (configurable via HUGGINGFACE_INPUT_TOKEN_COST and HUGGINGFACE_OUTPUT_TOKEN_COST env vars)"
}
]
# Search API Pricing (estimated)
search_pricing = [
{
@@ -268,21 +299,31 @@ class PricingService:
]
# Combine all pricing data
all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + search_pricing
all_pricing = gemini_pricing + openai_pricing + anthropic_pricing + mistral_pricing + search_pricing
# Insert pricing data
# Insert or update pricing data
for pricing_data in all_pricing:
existing = self.db.query(APIProviderPricing).filter(
APIProviderPricing.provider == pricing_data["provider"],
APIProviderPricing.model_name == pricing_data["model_name"]
).first()
if not existing:
if existing:
# Update existing pricing (especially for HuggingFace if env vars changed)
if pricing_data["provider"] == APIProvider.MISTRAL:
# Update HuggingFace pricing from env vars
existing.cost_per_input_token = pricing_data["cost_per_input_token"]
existing.cost_per_output_token = pricing_data["cost_per_output_token"]
existing.description = pricing_data["description"]
existing.updated_at = datetime.utcnow()
logger.debug(f"Updated pricing for {pricing_data['provider'].value}:{pricing_data['model_name']}")
else:
pricing = APIProviderPricing(**pricing_data)
self.db.add(pricing)
logger.debug(f"Added new pricing for {pricing_data['provider'].value}:{pricing_data['model_name']}")
self.db.commit()
logger.debug("Default API pricing initialized")
logger.info("Default API pricing initialized/updated. HuggingFace pricing loaded from env vars if available.")
def initialize_default_plans(self):
"""Initialize default subscription plans."""
@@ -395,31 +436,82 @@ class PricingService:
def calculate_api_cost(self, provider: APIProvider, model_name: str,
tokens_input: int = 0, tokens_output: int = 0,
request_count: int = 1, **kwargs) -> Dict[str, float]:
"""Calculate cost for an API call."""
"""Calculate cost for an API call.
Args:
provider: APIProvider enum (e.g., APIProvider.MISTRAL for HuggingFace)
model_name: Model name (e.g., "openai/gpt-oss-120b:groq")
tokens_input: Number of input tokens
tokens_output: Number of output tokens
request_count: Number of requests (default: 1)
**kwargs: Additional parameters (search_count, image_count, page_count, etc.)
Returns:
Dict with cost_input, cost_output, and cost_total
"""
# Get pricing for the provider and model
# Try exact match first
pricing = self.db.query(APIProviderPricing).filter(
APIProviderPricing.provider == provider,
APIProviderPricing.model_name == model_name,
APIProviderPricing.is_active == True
).first()
# If not found, try "default" model name for the provider
if not pricing:
logger.warning(f"No pricing found for {provider.value}:{model_name}, using default estimates")
# Use default estimates
cost_input = tokens_input * 0.000001 # $1 per 1M tokens default
cost_output = tokens_output * 0.000001
cost_total = (cost_input + cost_output) * request_count
pricing = self.db.query(APIProviderPricing).filter(
APIProviderPricing.provider == provider,
APIProviderPricing.model_name == "default",
APIProviderPricing.is_active == True
).first()
# If still not found, check for HuggingFace models (provider is MISTRAL)
# Try alternative model name variations
if not pricing and provider == APIProvider.MISTRAL:
# Try with "gpt-oss-120b" (without full path) if model contains it
if "gpt-oss-120b" in model_name.lower():
pricing = self.db.query(APIProviderPricing).filter(
APIProviderPricing.provider == provider,
APIProviderPricing.model_name == "gpt-oss-120b",
APIProviderPricing.is_active == True
).first()
# Also try with full model path
if not pricing:
pricing = self.db.query(APIProviderPricing).filter(
APIProviderPricing.provider == provider,
APIProviderPricing.model_name == "openai/gpt-oss-120b:groq",
APIProviderPricing.is_active == True
).first()
if not pricing:
# Check if we should use env vars for HuggingFace/Mistral
if provider == APIProvider.MISTRAL:
# Use environment variables for HuggingFace pricing if available
hf_input_cost = float(os.getenv('HUGGINGFACE_INPUT_TOKEN_COST', '0.000001'))
hf_output_cost = float(os.getenv('HUGGINGFACE_OUTPUT_TOKEN_COST', '0.000003'))
logger.info(f"Using HuggingFace pricing from env vars: input={hf_input_cost}, output={hf_output_cost} for model {model_name}")
cost_input = tokens_input * hf_input_cost
cost_output = tokens_output * hf_output_cost
cost_total = cost_input + cost_output
else:
logger.warning(f"No pricing found for {provider.value}:{model_name}, using default estimates")
# Use default estimates
cost_input = tokens_input * 0.000001 # $1 per 1M tokens default
cost_output = tokens_output * 0.000001
cost_total = cost_input + cost_output
else:
# Calculate based on actual pricing
cost_input = tokens_input * pricing.cost_per_input_token
cost_output = tokens_output * pricing.cost_per_output_token
cost_request = request_count * pricing.cost_per_request
# Calculate based on actual pricing from database
logger.debug(f"Using pricing from DB for {provider.value}:{model_name} - input: {pricing.cost_per_input_token}, output: {pricing.cost_per_output_token}")
cost_input = tokens_input * (pricing.cost_per_input_token or 0.0)
cost_output = tokens_output * (pricing.cost_per_output_token or 0.0)
cost_request = request_count * (pricing.cost_per_request or 0.0)
# Handle special cases for non-LLM APIs
cost_search = kwargs.get('search_count', 0) * pricing.cost_per_search
cost_image = kwargs.get('image_count', 0) * pricing.cost_per_image
cost_page = kwargs.get('page_count', 0) * pricing.cost_per_page
cost_search = kwargs.get('search_count', 0) * (pricing.cost_per_search or 0.0)
cost_image = kwargs.get('image_count', 0) * (pricing.cost_per_image or 0.0)
cost_page = kwargs.get('page_count', 0) * (pricing.cost_per_page or 0.0)
cost_total = cost_input + cost_output + cost_request + cost_search + cost_image + cost_page

View File

@@ -42,10 +42,19 @@ class UsageTrackingService:
default_models = {
"gemini": "gemini-2.5-flash", # Use Flash as default (cost-effective)
"openai": "gpt-4o-mini", # Use Mini as default (cost-effective)
"anthropic": "claude-3.5-sonnet" # Use Sonnet as default
"anthropic": "claude-3.5-sonnet", # Use Sonnet as default
"mistral": "openai/gpt-oss-120b:groq" # HuggingFace default model
}
model_name = model_used or default_models.get(provider.value, f"{provider.value}-default")
# For HuggingFace (stored as MISTRAL), use the actual model name or default
if provider == APIProvider.MISTRAL:
# HuggingFace models - try to match the actual model name from model_used
if model_used:
model_name = model_used
else:
model_name = default_models.get("mistral", "openai/gpt-oss-120b:groq")
else:
model_name = model_used or default_models.get(provider.value, f"{provider.value}-default")
cost_data = self.pricing_service.calculate_api_cost(
provider=provider,
@@ -344,46 +353,106 @@ class UsageTrackingService:
'limits': limits,
'provider_breakdown': provider_breakdown,
'alerts': [],
'usage_percentages': usage_percentages
'usage_percentages': {}
}
# Calculate usage percentages
# Provider breakdown - calculate costs first, then use for percentages
# Only include Gemini and HuggingFace (HuggingFace is stored under MISTRAL enum)
provider_breakdown = {}
# Gemini
gemini_calls = getattr(summary, "gemini_calls", 0) or 0
gemini_tokens = getattr(summary, "gemini_tokens", 0) or 0
gemini_cost = getattr(summary, "gemini_cost", 0.0) or 0.0
# If gemini cost is 0 but there are calls, calculate from usage logs
if gemini_calls > 0 and gemini_cost == 0.0:
gemini_logs = self.db.query(APIUsageLog).filter(
APIUsageLog.user_id == user_id,
APIUsageLog.provider == APIProvider.GEMINI,
APIUsageLog.billing_period == billing_period
).all()
if gemini_logs:
gemini_cost = sum(float(log.cost_total or 0.0) for log in gemini_logs)
logger.info(f"[UsageStats] Calculated gemini cost from {len(gemini_logs)} logs: ${gemini_cost:.6f}")
provider_breakdown['gemini'] = {
'calls': gemini_calls,
'tokens': gemini_tokens,
'cost': gemini_cost
}
# HuggingFace (stored as MISTRAL in database)
mistral_calls = getattr(summary, "mistral_calls", 0) or 0
mistral_tokens = getattr(summary, "mistral_tokens", 0) or 0
mistral_cost = getattr(summary, "mistral_cost", 0.0) or 0.0
# If mistral (HuggingFace) cost is 0 but there are calls, calculate from usage logs
if mistral_calls > 0 and mistral_cost == 0.0:
mistral_logs = self.db.query(APIUsageLog).filter(
APIUsageLog.user_id == user_id,
APIUsageLog.provider == APIProvider.MISTRAL,
APIUsageLog.billing_period == billing_period
).all()
if mistral_logs:
mistral_cost = sum(float(log.cost_total or 0.0) for log in mistral_logs)
logger.info(f"[UsageStats] Calculated mistral (HuggingFace) cost from {len(mistral_logs)} logs: ${mistral_cost:.6f}")
provider_breakdown['huggingface'] = {
'calls': mistral_calls,
'tokens': mistral_tokens,
'cost': mistral_cost
}
# Calculate total cost from provider breakdown if summary total_cost is 0
calculated_total_cost = gemini_cost + mistral_cost
summary_total_cost = summary.total_cost or 0.0
# Use calculated cost if summary cost is 0, otherwise use summary cost (it's more accurate)
final_total_cost = summary_total_cost if summary_total_cost > 0 else calculated_total_cost
# If we calculated costs from logs, update the summary for future requests
if calculated_total_cost > 0 and summary_total_cost == 0.0:
logger.info(f"[UsageStats] Updating summary costs: total_cost={final_total_cost:.6f}, gemini_cost={gemini_cost:.6f}, mistral_cost={mistral_cost:.6f}")
summary.total_cost = final_total_cost
summary.gemini_cost = gemini_cost
summary.mistral_cost = mistral_cost
try:
self.db.commit()
except Exception as e:
logger.error(f"[UsageStats] Error updating summary costs: {e}")
self.db.rollback()
# Calculate usage percentages - only for Gemini and HuggingFace
# Use the calculated costs for accurate percentages
usage_percentages = {}
if limits:
for provider in APIProvider:
provider_name = provider.value
current_calls = getattr(summary, f"{provider_name}_calls", 0) or 0
call_limit = limits['limits'].get(f"{provider_name}_calls", 0) or 0
if call_limit > 0:
usage_percentages[f"{provider_name}_calls"] = (current_calls / call_limit) * 100
else:
usage_percentages[f"{provider_name}_calls"] = 0
# Gemini
gemini_call_limit = limits['limits'].get("gemini_calls", 0) or 0
if gemini_call_limit > 0:
usage_percentages['gemini_calls'] = (gemini_calls / gemini_call_limit) * 100
else:
usage_percentages['gemini_calls'] = 0
# Cost usage percentage
# HuggingFace (stored as mistral in database)
mistral_call_limit = limits['limits'].get("mistral_calls", 0) or 0
if mistral_call_limit > 0:
usage_percentages['mistral_calls'] = (mistral_calls / mistral_call_limit) * 100
else:
usage_percentages['mistral_calls'] = 0
# Cost usage percentage - use final_total_cost (calculated from logs if needed)
cost_limit = limits['limits'].get('monthly_cost', 0) or 0
total_cost = summary.total_cost or 0
if cost_limit > 0:
usage_percentages['cost'] = (total_cost / cost_limit) * 100
usage_percentages['cost'] = (final_total_cost / cost_limit) * 100
else:
usage_percentages['cost'] = 0
# Provider breakdown
provider_breakdown = {}
for provider in APIProvider:
provider_name = provider.value
provider_breakdown[provider_name] = {
'calls': getattr(summary, f"{provider_name}_calls", 0) or 0,
'tokens': getattr(summary, f"{provider_name}_tokens", 0) or 0,
'cost': getattr(summary, f"{provider_name}_cost", 0.0) or 0.0
}
return {
'billing_period': billing_period,
'usage_status': summary.usage_status.value if hasattr(summary.usage_status, 'value') else str(summary.usage_status),
'total_calls': summary.total_calls or 0,
'total_tokens': summary.total_tokens or 0,
'total_cost': summary.total_cost or 0.0,
'total_cost': final_total_cost,
'avg_response_time': summary.avg_response_time or 0.0,
'error_rate': summary.error_rate or 0.0,
'limits': limits,

View File

@@ -77,7 +77,17 @@ class WixService:
# For now, return the direct OAuth URL as a fallback
# In production, this should call the Wix Redirects API
redirect_url = f"https://www.wix.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope=BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE&code_challenge={code_challenge}&code_challenge_method=S256&state={state}"
scope = (
"BLOG.CREATE-DRAFT,BLOG.PUBLISH-POST,BLOG.READ-CATEGORY," \
"BLOG.CREATE-CATEGORY,BLOG.READ-TAG,BLOG.CREATE-TAG," \
"MEDIA.SITE_MEDIA_FILES_IMPORT"
)
redirect_url = (
"https://www.wix.com/oauth/authorize?client_id="
f"{client_id}&redirect_uri={redirect_uri}&response_type=code"
f"&scope={scope}&code_challenge={code_challenge}"
f"&code_challenge_method=S256&state={state}"
)
logger.info(f"Generated Wix Headless OAuth redirect URL: {redirect_url}")
logger.warning("Using direct OAuth URL - should implement Redirects API for production")
@@ -293,9 +303,20 @@ class WixService:
Returns:
Created blog post information
"""
# Normalize access token to string to avoid type issues (can be dict/int from storage)
from services.integrations.wix.utils import normalize_token_string
normalized_token = normalize_token_string(access_token)
if normalized_token:
token_to_use = normalized_token.strip()
else:
token_to_use = str(access_token).strip() if access_token is not None else ""
if not token_to_use:
raise ValueError("access_token is required to create a blog post")
return publish_blog_post(
blog_service=self.blog_service,
access_token=access_token,
access_token=token_to_use,
title=title,
content=content,
member_id=member_id,