story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete
@@ -170,6 +170,13 @@ class RouterManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"AI Blog Writer router not mounted: {e}")
|
||||
|
||||
# Story Writer router
|
||||
try:
|
||||
from api.story_writer.router import router as story_writer_router
|
||||
self.include_router_safely(story_writer_router, "story_writer")
|
||||
except Exception as e:
|
||||
logger.warning(f"Story Writer router not mounted: {e}")
|
||||
|
||||
# Wix Integration router
|
||||
try:
|
||||
from api.wix_routes import router as wix_router
|
||||
|
||||
@@ -671,4 +671,122 @@ async def rewrite_status(task_id: str):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get rewrite status for {task_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/titles/generate-seo")
|
||||
async def generate_seo_titles(
|
||||
request: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate 5 SEO-optimized blog titles using research and outline data."""
|
||||
try:
|
||||
# Extract Clerk user ID (required)
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
# Import here to avoid circular dependencies
|
||||
from services.blog_writer.outline.seo_title_generator import SEOTitleGenerator
|
||||
from models.blog_models import BlogResearchResponse, BlogOutlineSection
|
||||
|
||||
# Parse request data
|
||||
research_data = request.get('research')
|
||||
outline_data = request.get('outline', [])
|
||||
primary_keywords = request.get('primary_keywords', [])
|
||||
secondary_keywords = request.get('secondary_keywords', [])
|
||||
content_angles = request.get('content_angles', [])
|
||||
search_intent = request.get('search_intent', 'informational')
|
||||
word_count = request.get('word_count', 1500)
|
||||
|
||||
if not research_data:
|
||||
raise HTTPException(status_code=400, detail="Research data is required")
|
||||
|
||||
# Convert to models
|
||||
research = BlogResearchResponse(**research_data)
|
||||
outline = [BlogOutlineSection(**section) for section in outline_data]
|
||||
|
||||
# Generate titles
|
||||
title_generator = SEOTitleGenerator()
|
||||
titles = await title_generator.generate_seo_titles(
|
||||
research=research,
|
||||
outline=outline,
|
||||
primary_keywords=primary_keywords,
|
||||
secondary_keywords=secondary_keywords,
|
||||
content_angles=content_angles,
|
||||
search_intent=search_intent,
|
||||
word_count=word_count,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"titles": titles
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate SEO titles: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/introductions/generate")
|
||||
async def generate_introductions(
|
||||
request: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate 3 varied blog introductions using research, outline, and content."""
|
||||
try:
|
||||
# Extract Clerk user ID (required)
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
user_id = str(current_user.get('id', ''))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
|
||||
|
||||
# Import here to avoid circular dependencies
|
||||
from services.blog_writer.content.introduction_generator import IntroductionGenerator
|
||||
from models.blog_models import BlogResearchResponse, BlogOutlineSection
|
||||
|
||||
# Parse request data
|
||||
blog_title = request.get('blog_title', '')
|
||||
research_data = request.get('research')
|
||||
outline_data = request.get('outline', [])
|
||||
sections_content = request.get('sections_content', {})
|
||||
primary_keywords = request.get('primary_keywords', [])
|
||||
search_intent = request.get('search_intent', 'informational')
|
||||
|
||||
if not research_data:
|
||||
raise HTTPException(status_code=400, detail="Research data is required")
|
||||
if not blog_title:
|
||||
raise HTTPException(status_code=400, detail="Blog title is required")
|
||||
|
||||
# Convert to models
|
||||
research = BlogResearchResponse(**research_data)
|
||||
outline = [BlogOutlineSection(**section) for section in outline_data]
|
||||
|
||||
# Generate introductions
|
||||
intro_generator = IntroductionGenerator()
|
||||
introductions = await intro_generator.generate_introductions(
|
||||
blog_title=blog_title,
|
||||
research=research,
|
||||
outline=outline,
|
||||
sections_content=sections_content,
|
||||
primary_keywords=primary_keywords,
|
||||
search_intent=search_intent,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"introductions": introductions
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate introductions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1151,6 +1151,118 @@ async def retry_website_analysis(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to retry website analysis: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tasks-needing-intervention/{user_id}")
|
||||
async def get_tasks_needing_intervention(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get all tasks that need human intervention.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
List of tasks needing intervention with failure pattern details
|
||||
"""
|
||||
try:
|
||||
# Verify user access
|
||||
if str(current_user.get('id')) != user_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
from services.scheduler.core.failure_detection_service import FailureDetectionService
|
||||
detection_service = FailureDetectionService(db)
|
||||
|
||||
tasks = detection_service.get_tasks_needing_intervention(user_id=user_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tasks": tasks,
|
||||
"count": len(tasks)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tasks needing intervention: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get tasks needing intervention: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{task_id}/manual-trigger")
|
||||
async def manual_trigger_task(
|
||||
task_type: str,
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Manually trigger a task that is in cool-off or needs intervention.
|
||||
This bypasses the cool-off check and executes the task immediately.
|
||||
|
||||
Args:
|
||||
task_type: Task type (oauth_token_monitoring, website_analysis, gsc_insights, bing_insights)
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
Success status and execution result
|
||||
"""
|
||||
try:
|
||||
from services.scheduler.core.task_execution_handler import execute_task_async
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Load task based on type
|
||||
task = None
|
||||
if task_type == "oauth_token_monitoring":
|
||||
task = db.query(OAuthTokenMonitoringTask).filter(
|
||||
OAuthTokenMonitoringTask.id == task_id
|
||||
).first()
|
||||
elif task_type == "website_analysis":
|
||||
task = db.query(WebsiteAnalysisTask).filter(
|
||||
WebsiteAnalysisTask.id == task_id
|
||||
).first()
|
||||
elif task_type in ["gsc_insights", "bing_insights"]:
|
||||
task = db.query(PlatformInsightsTask).filter(
|
||||
PlatformInsightsTask.id == task_id
|
||||
).first()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Verify user access
|
||||
if str(current_user.get('id')) != task.user_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Clear cool-off status and reset failure count
|
||||
task.status = "active"
|
||||
task.consecutive_failures = 0
|
||||
task.failure_pattern = None
|
||||
|
||||
# Execute task manually (bypasses cool-off check)
|
||||
# Task types are registered as: oauth_token_monitoring, website_analysis, gsc_insights, bing_insights
|
||||
await execute_task_async(scheduler, task_type, task, execution_source="manual")
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Manually triggered task {task_id} ({task_type}) for user {task.user_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Task triggered successfully",
|
||||
"task": {
|
||||
"id": task.id,
|
||||
"status": task.status,
|
||||
"last_check": task.last_check.isoformat() if task.last_check else None
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error manually triggering task {task_id}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to trigger task: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/platform-insights/logs/{user_id}")
|
||||
async def get_platform_insights_logs(
|
||||
user_id: str,
|
||||
|
||||
9
backend/api/story_writer/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Story Writer API
|
||||
|
||||
API endpoints for story generation functionality.
|
||||
"""
|
||||
|
||||
from .router import router
|
||||
|
||||
__all__ = ['router']
|
||||
70
backend/api/story_writer/cache_manager.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Cache Management System for Story Writer API
|
||||
|
||||
Handles story generation cache operations.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Manages cache operations for story generation data."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the cache manager."""
|
||||
self.cache: Dict[str, Dict[str, Any]] = {}
|
||||
logger.info("[StoryWriter] CacheManager initialized")
|
||||
|
||||
def get_cache_key(self, request_data: Dict[str, Any]) -> str:
|
||||
"""Generate a cache key from request data."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
# Create a normalized version of the request for caching
|
||||
cache_data = {
|
||||
"persona": request_data.get("persona", ""),
|
||||
"story_setting": request_data.get("story_setting", ""),
|
||||
"character_input": request_data.get("character_input", ""),
|
||||
"plot_elements": request_data.get("plot_elements", ""),
|
||||
"writing_style": request_data.get("writing_style", ""),
|
||||
"story_tone": request_data.get("story_tone", ""),
|
||||
"narrative_pov": request_data.get("narrative_pov", ""),
|
||||
"audience_age_group": request_data.get("audience_age_group", ""),
|
||||
"content_rating": request_data.get("content_rating", ""),
|
||||
"ending_preference": request_data.get("ending_preference", ""),
|
||||
}
|
||||
|
||||
cache_str = json.dumps(cache_data, sort_keys=True)
|
||||
return hashlib.md5(cache_str.encode()).hexdigest()
|
||||
|
||||
def get_cached_result(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a cached result if available."""
|
||||
if cache_key in self.cache:
|
||||
logger.debug(f"[StoryWriter] Cache hit for key: {cache_key}")
|
||||
return self.cache[cache_key]
|
||||
logger.debug(f"[StoryWriter] Cache miss for key: {cache_key}")
|
||||
return None
|
||||
|
||||
def cache_result(self, cache_key: str, result: Dict[str, Any]):
|
||||
"""Cache a result."""
|
||||
self.cache[cache_key] = result
|
||||
logger.debug(f"[StoryWriter] Cached result for key: {cache_key}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all cached results."""
|
||||
count = len(self.cache)
|
||||
self.cache.clear()
|
||||
logger.info(f"[StoryWriter] Cleared {count} cached entries")
|
||||
return {"status": "success", "message": f"Cleared {count} cached entries"}
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics."""
|
||||
return {
|
||||
"total_entries": len(self.cache),
|
||||
"cache_keys": list(self.cache.keys())
|
||||
}
|
||||
|
||||
|
||||
# Global cache manager instance
|
||||
cache_manager = CacheManager()
|
||||
1181
backend/api/story_writer/router.py
Normal file
251
backend/api/story_writer/task_manager.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Task Management System for Story Writer API
|
||||
|
||||
Handles background task execution, status tracking, and progress updates
|
||||
for story generation operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Manages background tasks for story generation."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the task manager."""
|
||||
self.task_storage: Dict[str, Dict[str, Any]] = {}
|
||||
logger.info("[StoryWriter] TaskManager initialized")
|
||||
|
||||
def cleanup_old_tasks(self):
|
||||
"""Remove tasks older than 1 hour to prevent memory leaks."""
|
||||
current_time = datetime.now()
|
||||
tasks_to_remove = []
|
||||
|
||||
for task_id, task_data in self.task_storage.items():
|
||||
created_at = task_data.get("created_at")
|
||||
if created_at and (current_time - created_at).total_seconds() > 3600: # 1 hour
|
||||
tasks_to_remove.append(task_id)
|
||||
|
||||
for task_id in tasks_to_remove:
|
||||
del self.task_storage[task_id]
|
||||
logger.debug(f"[StoryWriter] Cleaned up old task: {task_id}")
|
||||
|
||||
def create_task(self, task_type: str = "story_generation") -> str:
|
||||
"""Create a new task and return its ID."""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
self.task_storage[task_id] = {
|
||||
"status": "pending",
|
||||
"created_at": datetime.now(),
|
||||
"result": None,
|
||||
"error": None,
|
||||
"progress_messages": [],
|
||||
"task_type": task_type,
|
||||
"progress": 0.0
|
||||
}
|
||||
|
||||
logger.info(f"[StoryWriter] Created task: {task_id} (type: {task_type})")
|
||||
return task_id
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get the status of a task."""
|
||||
self.cleanup_old_tasks()
|
||||
|
||||
if task_id not in self.task_storage:
|
||||
logger.warning(f"[StoryWriter] Task not found: {task_id}")
|
||||
return None
|
||||
|
||||
task = self.task_storage[task_id]
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task["status"],
|
||||
"progress": task.get("progress", 0.0),
|
||||
"message": task.get("progress_messages", [])[-1] if task.get("progress_messages") else None,
|
||||
"created_at": task["created_at"].isoformat() if task.get("created_at") else None,
|
||||
"updated_at": task.get("updated_at", task.get("created_at")).isoformat() if task.get("updated_at") or task.get("created_at") else None,
|
||||
}
|
||||
|
||||
if task["status"] == "completed" and task.get("result"):
|
||||
response["result"] = task["result"]
|
||||
|
||||
if task["status"] == "failed" and task.get("error"):
|
||||
response["error"] = task["error"]
|
||||
|
||||
return response
|
||||
|
||||
def update_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[float] = None,
|
||||
message: Optional[str] = None,
|
||||
result: Optional[Dict[str, Any]] = None,
|
||||
error: Optional[str] = None
|
||||
):
|
||||
"""Update the status of a task."""
|
||||
if task_id not in self.task_storage:
|
||||
logger.warning(f"[StoryWriter] Cannot update non-existent task: {task_id}")
|
||||
return
|
||||
|
||||
task = self.task_storage[task_id]
|
||||
task["status"] = status
|
||||
task["updated_at"] = datetime.now()
|
||||
|
||||
if progress is not None:
|
||||
task["progress"] = progress
|
||||
|
||||
if message:
|
||||
if "progress_messages" not in task:
|
||||
task["progress_messages"] = []
|
||||
task["progress_messages"].append(message)
|
||||
logger.info(f"[StoryWriter] Task {task_id}: {message} (progress: {progress}%)")
|
||||
|
||||
if result is not None:
|
||||
task["result"] = result
|
||||
|
||||
if error is not None:
|
||||
task["error"] = error
|
||||
logger.error(f"[StoryWriter] Task {task_id} error: {error}")
|
||||
|
||||
async def execute_story_generation_task(
|
||||
self,
|
||||
task_id: str,
|
||||
request_data: Dict[str, Any],
|
||||
user_id: str
|
||||
):
|
||||
"""Execute story generation task asynchronously."""
|
||||
from services.story_writer.story_service import StoryWriterService
|
||||
|
||||
service = StoryWriterService()
|
||||
|
||||
try:
|
||||
self.update_task_status(task_id, "processing", progress=0.0, message="Starting story generation...")
|
||||
|
||||
# Step 1: Generate premise
|
||||
self.update_task_status(task_id, "processing", progress=10.0, message="Generating story premise...")
|
||||
premise = service.generate_premise(
|
||||
persona=request_data["persona"],
|
||||
story_setting=request_data["story_setting"],
|
||||
character_input=request_data["character_input"],
|
||||
plot_elements=request_data["plot_elements"],
|
||||
writing_style=request_data["writing_style"],
|
||||
story_tone=request_data["story_tone"],
|
||||
narrative_pov=request_data["narrative_pov"],
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Step 2: Generate outline
|
||||
self.update_task_status(task_id, "processing", progress=30.0, message="Generating story outline...")
|
||||
outline = service.generate_outline(
|
||||
premise=premise,
|
||||
persona=request_data["persona"],
|
||||
story_setting=request_data["story_setting"],
|
||||
character_input=request_data["character_input"],
|
||||
plot_elements=request_data["plot_elements"],
|
||||
writing_style=request_data["writing_style"],
|
||||
story_tone=request_data["story_tone"],
|
||||
narrative_pov=request_data["narrative_pov"],
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Step 3: Generate story start
|
||||
self.update_task_status(task_id, "processing", progress=50.0, message="Writing story beginning...")
|
||||
story_start = service.generate_story_start(
|
||||
premise=premise,
|
||||
outline=outline,
|
||||
persona=request_data["persona"],
|
||||
story_setting=request_data["story_setting"],
|
||||
character_input=request_data["character_input"],
|
||||
plot_elements=request_data["plot_elements"],
|
||||
writing_style=request_data["writing_style"],
|
||||
story_tone=request_data["story_tone"],
|
||||
narrative_pov=request_data["narrative_pov"],
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Step 4: Continue story
|
||||
self.update_task_status(task_id, "processing", progress=70.0, message="Continuing story generation...")
|
||||
story_text = story_start
|
||||
max_iterations = request_data.get("max_iterations", 10)
|
||||
iteration = 0
|
||||
|
||||
while 'IAMDONE' not in story_text and iteration < max_iterations:
|
||||
iteration += 1
|
||||
progress = 70.0 + (iteration / max_iterations) * 25.0
|
||||
self.update_task_status(
|
||||
task_id,
|
||||
"processing",
|
||||
progress=min(progress, 95.0),
|
||||
message=f"Writing continuation {iteration}/{max_iterations}..."
|
||||
)
|
||||
|
||||
continuation = service.continue_story(
|
||||
premise=premise,
|
||||
outline=outline,
|
||||
story_text=story_text,
|
||||
persona=request_data["persona"],
|
||||
story_setting=request_data["story_setting"],
|
||||
character_input=request_data["character_input"],
|
||||
plot_elements=request_data["plot_elements"],
|
||||
writing_style=request_data["writing_style"],
|
||||
story_tone=request_data["story_tone"],
|
||||
narrative_pov=request_data["narrative_pov"],
|
||||
audience_age_group=request_data["audience_age_group"],
|
||||
content_rating=request_data["content_rating"],
|
||||
ending_preference=request_data["ending_preference"],
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if continuation:
|
||||
story_text += '\n\n' + continuation
|
||||
else:
|
||||
logger.warning(f"[StoryWriter] Empty continuation at iteration {iteration}")
|
||||
break
|
||||
|
||||
# Clean up and finalize
|
||||
final_story = story_text.replace('IAMDONE', '').strip()
|
||||
|
||||
result = {
|
||||
"premise": premise,
|
||||
"outline": outline,
|
||||
"story": final_story,
|
||||
"is_complete": 'IAMDONE' in story_text or iteration >= max_iterations,
|
||||
"iterations": iteration
|
||||
}
|
||||
|
||||
self.update_task_status(
|
||||
task_id,
|
||||
"completed",
|
||||
progress=100.0,
|
||||
message="Story generation completed!",
|
||||
result=result
|
||||
)
|
||||
|
||||
logger.info(f"[StoryWriter] Task {task_id} completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"[StoryWriter] Task {task_id} failed: {error_msg}")
|
||||
self.update_task_status(
|
||||
task_id,
|
||||
"failed",
|
||||
error=error_msg,
|
||||
message=f"Story generation failed: {error_msg}"
|
||||
)
|
||||
|
||||
|
||||
# Global task manager instance
|
||||
task_manager = TaskManager()
|
||||
@@ -5,6 +5,7 @@ Provides endpoints for subscription management and usage monitoring.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
@@ -12,12 +13,14 @@ from functools import lru_cache
|
||||
|
||||
from services.database import get_db
|
||||
from services.subscription import UsageTrackingService, PricingService
|
||||
from services.subscription.log_wrapping_service import LogWrappingService
|
||||
from services.subscription.schema_utils import ensure_subscription_plan_columns
|
||||
import sqlite3
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from models.subscription_models import (
|
||||
APIProvider, SubscriptionPlan, UserSubscription, UsageSummary,
|
||||
APIProviderPricing, UsageAlert, SubscriptionTier, BillingCycle, UsageStatus
|
||||
APIProviderPricing, UsageAlert, SubscriptionTier, BillingCycle, UsageStatus,
|
||||
APIUsageLog, SubscriptionRenewalHistory
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/subscription", tags=["subscription"])
|
||||
@@ -525,8 +528,67 @@ async def subscribe_to_plan(
|
||||
).first()
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
|
||||
# Track renewal history - capture BEFORE updating subscription
|
||||
previous_period_start = None
|
||||
previous_period_end = None
|
||||
previous_plan_name = None
|
||||
previous_plan_tier = None
|
||||
renewal_type = "new"
|
||||
renewal_count = 0
|
||||
|
||||
# Get usage snapshot BEFORE renewal (capture current state)
|
||||
usage_before_snapshot = None
|
||||
current_period = datetime.utcnow().strftime("%Y-%m")
|
||||
usage_before = db.query(UsageSummary).filter(
|
||||
UsageSummary.user_id == user_id,
|
||||
UsageSummary.billing_period == current_period
|
||||
).first()
|
||||
|
||||
if usage_before:
|
||||
usage_before_snapshot = {
|
||||
"total_calls": usage_before.total_calls or 0,
|
||||
"total_tokens": usage_before.total_tokens or 0,
|
||||
"total_cost": float(usage_before.total_cost) if usage_before.total_cost else 0.0,
|
||||
"gemini_calls": usage_before.gemini_calls or 0,
|
||||
"mistral_calls": usage_before.mistral_calls or 0,
|
||||
"usage_status": usage_before.usage_status.value if hasattr(usage_before.usage_status, 'value') else str(usage_before.usage_status)
|
||||
}
|
||||
|
||||
if existing_subscription:
|
||||
# This is a renewal/update - capture previous subscription state BEFORE updating
|
||||
previous_period_start = existing_subscription.current_period_start
|
||||
previous_period_end = existing_subscription.current_period_end
|
||||
previous_plan = existing_subscription.plan
|
||||
previous_plan_name = previous_plan.name if previous_plan else None
|
||||
previous_plan_tier = previous_plan.tier.value if previous_plan else None
|
||||
|
||||
# Determine renewal type
|
||||
if previous_plan and previous_plan.id == plan_id:
|
||||
# Same plan - this is a renewal
|
||||
renewal_type = "renewal"
|
||||
elif previous_plan:
|
||||
# Different plan - check if upgrade or downgrade
|
||||
tier_order = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
|
||||
previous_tier_order = tier_order.get(previous_plan_tier or "free", 0)
|
||||
new_tier_order = tier_order.get(plan.tier.value, 0)
|
||||
if new_tier_order > previous_tier_order:
|
||||
renewal_type = "upgrade"
|
||||
elif new_tier_order < previous_tier_order:
|
||||
renewal_type = "downgrade"
|
||||
else:
|
||||
renewal_type = "renewal" # Same tier, different plan name
|
||||
|
||||
# Get renewal count (how many times this user has renewed)
|
||||
last_renewal = db.query(SubscriptionRenewalHistory).filter(
|
||||
SubscriptionRenewalHistory.user_id == user_id
|
||||
).order_by(SubscriptionRenewalHistory.created_at.desc()).first()
|
||||
|
||||
if last_renewal:
|
||||
renewal_count = last_renewal.renewal_count + 1
|
||||
else:
|
||||
renewal_count = 1 # First renewal
|
||||
|
||||
# Update existing subscription
|
||||
existing_subscription.plan_id = plan_id
|
||||
existing_subscription.billing_cycle = BillingCycle(billing_cycle)
|
||||
@@ -552,7 +614,30 @@ async def subscribe_to_plan(
|
||||
auto_renew=True
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
|
||||
db.commit()
|
||||
|
||||
# Create renewal history record AFTER subscription update (so we have the new period_end)
|
||||
renewal_history = SubscriptionRenewalHistory(
|
||||
user_id=user_id,
|
||||
plan_id=plan_id,
|
||||
plan_name=plan.name,
|
||||
plan_tier=plan.tier.value,
|
||||
previous_period_start=previous_period_start,
|
||||
previous_period_end=previous_period_end,
|
||||
new_period_start=now,
|
||||
new_period_end=subscription.current_period_end,
|
||||
billing_cycle=BillingCycle(billing_cycle),
|
||||
renewal_type=renewal_type,
|
||||
renewal_count=renewal_count,
|
||||
previous_plan_name=previous_plan_name,
|
||||
previous_plan_tier=previous_plan_tier,
|
||||
usage_before_renewal=usage_before_snapshot, # Usage snapshot captured BEFORE renewal
|
||||
payment_amount=plan.price_yearly if billing_cycle == 'yearly' else plan.price_monthly,
|
||||
payment_status="paid", # Assume paid for now (can be updated if payment processing is added)
|
||||
payment_date=now
|
||||
)
|
||||
db.add(renewal_history)
|
||||
db.commit()
|
||||
|
||||
# Get current usage BEFORE reset for logging
|
||||
@@ -883,4 +968,222 @@ async def get_dashboard_data(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting dashboard data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/renewal-history/{user_id}")
|
||||
async def get_renewal_history(
|
||||
user_id: str,
|
||||
limit: int = Query(50, ge=1, le=100, description="Number of records to return"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get subscription renewal history for a user.
|
||||
|
||||
Returns:
|
||||
- List of renewal history records
|
||||
- Total count for pagination
|
||||
"""
|
||||
try:
|
||||
# Verify user can only access their own data
|
||||
if current_user.get('id') != user_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Get total count
|
||||
total_count = db.query(SubscriptionRenewalHistory).filter(
|
||||
SubscriptionRenewalHistory.user_id == user_id
|
||||
).count()
|
||||
|
||||
# Get paginated results, ordered by created_at descending (most recent first)
|
||||
renewals = db.query(SubscriptionRenewalHistory).filter(
|
||||
SubscriptionRenewalHistory.user_id == user_id
|
||||
).order_by(SubscriptionRenewalHistory.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Format renewal history for response
|
||||
renewal_history = []
|
||||
for renewal in renewals:
|
||||
renewal_history.append({
|
||||
'id': renewal.id,
|
||||
'plan_name': renewal.plan_name,
|
||||
'plan_tier': renewal.plan_tier,
|
||||
'previous_period_start': renewal.previous_period_start.isoformat() if renewal.previous_period_start else None,
|
||||
'previous_period_end': renewal.previous_period_end.isoformat() if renewal.previous_period_end else None,
|
||||
'new_period_start': renewal.new_period_start.isoformat() if renewal.new_period_start else None,
|
||||
'new_period_end': renewal.new_period_end.isoformat() if renewal.new_period_end else None,
|
||||
'billing_cycle': renewal.billing_cycle.value if renewal.billing_cycle else None,
|
||||
'renewal_type': renewal.renewal_type,
|
||||
'renewal_count': renewal.renewal_count,
|
||||
'previous_plan_name': renewal.previous_plan_name,
|
||||
'previous_plan_tier': renewal.previous_plan_tier,
|
||||
'usage_before_renewal': renewal.usage_before_renewal,
|
||||
'payment_amount': float(renewal.payment_amount) if renewal.payment_amount else 0.0,
|
||||
'payment_status': renewal.payment_status,
|
||||
'payment_date': renewal.payment_date.isoformat() if renewal.payment_date else None,
|
||||
'created_at': renewal.created_at.isoformat() if renewal.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"renewals": renewal_history,
|
||||
"total_count": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total_count
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting renewal history: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/usage-logs")
|
||||
async def get_usage_logs(
|
||||
limit: int = Query(50, ge=1, le=5000, description="Number of logs to return"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
provider: Optional[str] = Query(None, description="Filter by provider"),
|
||||
status_code: Optional[int] = Query(None, description="Filter by HTTP status code"),
|
||||
billing_period: Optional[str] = Query(None, description="Filter by billing period (YYYY-MM)"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get API usage logs for the current user.
|
||||
|
||||
Query Params:
|
||||
- limit: Number of logs to return (1-500, default: 50)
|
||||
- offset: Pagination offset (default: 0)
|
||||
- provider: Filter by provider (e.g., "gemini", "openai", "huggingface")
|
||||
- status_code: Filter by HTTP status code (e.g., 200 for success, 400+ for errors)
|
||||
- billing_period: Filter by billing period (YYYY-MM format)
|
||||
|
||||
Returns:
|
||||
- List of usage logs with API call details
|
||||
- Total count for pagination
|
||||
"""
|
||||
try:
|
||||
# Get user_id from current_user
|
||||
user_id = str(current_user.get('id', '')) if current_user else None
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User not authenticated")
|
||||
|
||||
# Build query
|
||||
query = db.query(APIUsageLog).filter(
|
||||
APIUsageLog.user_id == user_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if provider:
|
||||
provider_lower = provider.lower()
|
||||
# Handle special case: huggingface maps to MISTRAL enum in database
|
||||
if provider_lower == "huggingface":
|
||||
provider_enum = APIProvider.MISTRAL
|
||||
else:
|
||||
try:
|
||||
provider_enum = APIProvider(provider_lower)
|
||||
except ValueError:
|
||||
# Invalid provider, return empty results
|
||||
return {
|
||||
"logs": [],
|
||||
"total_count": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False
|
||||
}
|
||||
query = query.filter(APIUsageLog.provider == provider_enum)
|
||||
|
||||
if status_code is not None:
|
||||
query = query.filter(APIUsageLog.status_code == status_code)
|
||||
|
||||
if billing_period:
|
||||
query = query.filter(APIUsageLog.billing_period == billing_period)
|
||||
|
||||
# Check and wrap logs if necessary (before getting count)
|
||||
wrapping_service = LogWrappingService(db)
|
||||
wrap_result = wrapping_service.check_and_wrap_logs(user_id)
|
||||
if wrap_result.get('wrapped'):
|
||||
logger.info(f"[UsageLogs] Log wrapping completed for user {user_id}: {wrap_result.get('message')}")
|
||||
# Rebuild query after wrapping (in case filters changed)
|
||||
query = db.query(APIUsageLog).filter(
|
||||
APIUsageLog.user_id == user_id
|
||||
)
|
||||
# Reapply filters
|
||||
if provider:
|
||||
provider_lower = provider.lower()
|
||||
if provider_lower == "huggingface":
|
||||
provider_enum = APIProvider.MISTRAL
|
||||
else:
|
||||
try:
|
||||
provider_enum = APIProvider(provider_lower)
|
||||
except ValueError:
|
||||
return {
|
||||
"logs": [],
|
||||
"total_count": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False
|
||||
}
|
||||
query = query.filter(APIUsageLog.provider == provider_enum)
|
||||
if status_code is not None:
|
||||
query = query.filter(APIUsageLog.status_code == status_code)
|
||||
if billing_period:
|
||||
query = query.filter(APIUsageLog.billing_period == billing_period)
|
||||
|
||||
# Get total count
|
||||
total_count = query.count()
|
||||
|
||||
# Get paginated results, ordered by timestamp descending (most recent first)
|
||||
logs = query.order_by(desc(APIUsageLog.timestamp)).offset(offset).limit(limit).all()
|
||||
|
||||
# Format logs for response
|
||||
formatted_logs = []
|
||||
for log in logs:
|
||||
# Determine status based on status_code
|
||||
status = 'success' if 200 <= log.status_code < 300 else 'failed'
|
||||
|
||||
# Handle provider display name - ALL MISTRAL enum logs are actually HuggingFace
|
||||
# (HuggingFace always maps to MISTRAL enum in the database)
|
||||
provider_display = log.provider.value if log.provider else None
|
||||
if provider_display == "mistral":
|
||||
# All MISTRAL provider logs are HuggingFace calls
|
||||
provider_display = "huggingface"
|
||||
|
||||
formatted_logs.append({
|
||||
'id': log.id,
|
||||
'timestamp': log.timestamp.isoformat() if log.timestamp else None,
|
||||
'provider': provider_display,
|
||||
'model_used': log.model_used,
|
||||
'endpoint': log.endpoint,
|
||||
'method': log.method,
|
||||
'tokens_input': log.tokens_input or 0,
|
||||
'tokens_output': log.tokens_output or 0,
|
||||
'tokens_total': log.tokens_total or 0,
|
||||
'cost_input': float(log.cost_input) if log.cost_input else 0.0,
|
||||
'cost_output': float(log.cost_output) if log.cost_output else 0.0,
|
||||
'cost_total': float(log.cost_total) if log.cost_total else 0.0,
|
||||
'response_time': float(log.response_time) if log.response_time else 0.0,
|
||||
'status_code': log.status_code,
|
||||
'status': status,
|
||||
'error_message': log.error_message,
|
||||
'billing_period': log.billing_period,
|
||||
'retry_count': log.retry_count or 0,
|
||||
'is_aggregated': log.endpoint == "[AGGREGATED]" # Flag to indicate aggregated log
|
||||
})
|
||||
|
||||
return {
|
||||
"logs": formatted_logs,
|
||||
"total_count": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total_count
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting usage logs: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get usage logs: {str(e)}")
|
||||
@@ -498,7 +498,15 @@ async def get_test_authorization_url(state: Optional[str] = None) -> Dict[str, s
|
||||
if not wix_service.client_id:
|
||||
logger.warning("TEST: Wix Client ID not configured, returning mock URL")
|
||||
return {
|
||||
"url": "https://www.wix.com/oauth/access?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3000/wix/callback&response_type=code&scope=BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE&code_challenge=test&code_challenge_method=S256",
|
||||
"url": (
|
||||
"https://www.wix.com/oauth/access?client_id=YOUR_CLIENT_ID"
|
||||
"&redirect_uri=http://localhost:3000/wix/callback"
|
||||
"&response_type=code&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=test&code_challenge_method=S256"
|
||||
),
|
||||
"state": state or "test_state",
|
||||
"message": "WIX_CLIENT_ID not configured. Please set it in your .env file to get a real authorization URL."
|
||||
}
|
||||
@@ -573,9 +581,19 @@ async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
- Derives member_id server-side (required by Wix for third-party apps)
|
||||
"""
|
||||
try:
|
||||
access_token = payload.get("access_token")
|
||||
if not access_token:
|
||||
# Normalize access_token from payload (could be string, dict, or other format)
|
||||
from services.integrations.wix.utils import normalize_token_string
|
||||
raw_access_token = payload.get("access_token")
|
||||
if not raw_access_token:
|
||||
raise HTTPException(status_code=400, detail="Missing access_token")
|
||||
|
||||
# Normalize token to string (handles dict with accessToken.value, int, etc.)
|
||||
access_token = normalize_token_string(raw_access_token)
|
||||
if not access_token:
|
||||
# Fallback: try to convert to string directly
|
||||
access_token = str(raw_access_token).strip()
|
||||
if not access_token or access_token == "None":
|
||||
raise HTTPException(status_code=400, detail="Invalid access_token format")
|
||||
|
||||
# Derive current member id from token (try local decode first, then API fallback)
|
||||
member_id = wix_service.extract_member_id_from_access_token(access_token)
|
||||
|
||||
@@ -16,7 +16,15 @@ from services.subscription import monitoring_middleware
|
||||
from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager, OnboardingManager
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
# Try multiple locations for .env file
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).parent
|
||||
project_root = backend_dir.parent
|
||||
|
||||
# Load from backend/.env first (higher priority), then root .env
|
||||
load_dotenv(backend_dir / '.env') # backend/.env
|
||||
load_dotenv(project_root / '.env') # root .env (fallback)
|
||||
load_dotenv() # CWD .env (fallback)
|
||||
|
||||
# Set up clean logging for end users
|
||||
from logging_config import setup_clean_logging
|
||||
@@ -318,6 +326,13 @@ async def startup_event():
|
||||
from services.scheduler import get_scheduler
|
||||
await get_scheduler().start()
|
||||
|
||||
# Check Wix API key configuration
|
||||
wix_api_key = os.getenv('WIX_API_KEY')
|
||||
if wix_api_key:
|
||||
logger.warning(f"✅ WIX_API_KEY loaded ({len(wix_api_key)} chars, starts with '{wix_api_key[:10]}...')")
|
||||
else:
|
||||
logger.warning("⚠️ WIX_API_KEY not found in environment - Wix publishing may fail")
|
||||
|
||||
logger.info("ALwrity backend started successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during startup: {e}")
|
||||
|
||||
@@ -26,7 +26,7 @@ class OAuthTokenMonitoringTask(Base):
|
||||
platform = Column(String(50), nullable=False) # 'gsc', 'bing', 'wordpress', 'wix'
|
||||
|
||||
# Task Status
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused'
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused', 'needs_intervention'
|
||||
|
||||
# Execution Tracking
|
||||
last_check = Column(DateTime, nullable=True)
|
||||
@@ -34,6 +34,10 @@ class OAuthTokenMonitoringTask(Base):
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
# Failure Pattern Tracking
|
||||
consecutive_failures = Column(Integer, default=0) # Count of consecutive failures
|
||||
failure_pattern = Column(JSON, nullable=True) # JSON storing failure analysis
|
||||
|
||||
# Scheduling
|
||||
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class PlatformInsightsTask(Base):
|
||||
site_url = Column(String(500), nullable=True) # Optional: specific site URL
|
||||
|
||||
# Task Status
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused'
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused', 'needs_intervention'
|
||||
|
||||
# Execution Tracking
|
||||
last_check = Column(DateTime, nullable=True)
|
||||
@@ -35,6 +35,10 @@ class PlatformInsightsTask(Base):
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
# Failure Pattern Tracking
|
||||
consecutive_failures = Column(Integer, default=0) # Count of consecutive failures
|
||||
failure_pattern = Column(JSON, nullable=True) # JSON storing failure analysis
|
||||
|
||||
# Scheduling
|
||||
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
|
||||
|
||||
|
||||
262
backend/models/story_models.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Story Writer Models
|
||||
|
||||
Pydantic models for story generation API requests and responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
|
||||
class StoryGenerationRequest(BaseModel):
|
||||
"""Request model for story generation."""
|
||||
persona: str = Field(..., description="The persona statement for the author")
|
||||
story_setting: str = Field(..., description="The setting of the story")
|
||||
character_input: str = Field(..., description="The characters in the story")
|
||||
plot_elements: str = Field(..., description="The plot elements of the story")
|
||||
writing_style: str = Field(..., description="The writing style (e.g., Formal, Casual, Poetic, Humorous)")
|
||||
story_tone: str = Field(..., description="The tone of the story (e.g., Dark, Uplifting, Suspenseful, Whimsical)")
|
||||
narrative_pov: str = Field(..., description="The narrative point of view (e.g., First Person, Third Person Limited, Third Person Omniscient)")
|
||||
audience_age_group: str = Field(..., description="The target audience age group (e.g., Children, Young Adults, Adults)")
|
||||
content_rating: str = Field(..., description="The content rating (e.g., G, PG, PG-13, R)")
|
||||
ending_preference: str = Field(..., description="The preferred ending (e.g., Happy, Tragic, Cliffhanger, Twist)")
|
||||
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
|
||||
enable_explainer: bool = Field(default=True, description="Enable explainer features")
|
||||
enable_illustration: bool = Field(default=True, description="Enable illustration features")
|
||||
enable_video_narration: bool = Field(default=True, description="Enable story video and narration features")
|
||||
|
||||
# Image generation settings
|
||||
image_provider: Optional[str] = Field(default=None, description="Image generation provider (gemini, huggingface, stability)")
|
||||
image_width: int = Field(default=1024, description="Image width in pixels")
|
||||
image_height: int = Field(default=1024, description="Image height in pixels")
|
||||
image_model: Optional[str] = Field(default=None, description="Image generation model")
|
||||
|
||||
# Video generation settings
|
||||
video_fps: int = Field(default=24, description="Frames per second for video")
|
||||
video_transition_duration: float = Field(default=0.5, description="Duration of transitions between scenes in seconds")
|
||||
|
||||
# Audio generation settings
|
||||
audio_provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
|
||||
audio_lang: str = Field(default="en", description="Language code for TTS")
|
||||
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
|
||||
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
|
||||
|
||||
|
||||
class StorySetupGenerationRequest(BaseModel):
|
||||
"""Request model for AI story setup generation."""
|
||||
story_idea: str = Field(..., description="Basic story idea or information from the user")
|
||||
|
||||
|
||||
class StorySetupOption(BaseModel):
|
||||
"""A single story setup option."""
|
||||
persona: str = Field(..., description="The persona statement for the author")
|
||||
story_setting: str = Field(..., description="The setting of the story")
|
||||
character_input: str = Field(..., description="The characters in the story")
|
||||
plot_elements: str = Field(..., description="The plot elements of the story")
|
||||
writing_style: str = Field(..., description="The writing style")
|
||||
story_tone: str = Field(..., description="The tone of the story")
|
||||
narrative_pov: str = Field(..., description="The narrative point of view")
|
||||
audience_age_group: str = Field(..., description="The target audience age group")
|
||||
content_rating: str = Field(..., description="The content rating")
|
||||
ending_preference: str = Field(..., description="The preferred ending")
|
||||
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
|
||||
premise: str = Field(..., description="The story premise (1-2 sentences)")
|
||||
reasoning: str = Field(..., description="Brief reasoning for this setup option")
|
||||
|
||||
# Image generation settings
|
||||
image_provider: Optional[str] = Field(default=None, description="Image generation provider (gemini, huggingface, stability)")
|
||||
image_width: int = Field(default=1024, description="Image width in pixels")
|
||||
image_height: int = Field(default=1024, description="Image height in pixels")
|
||||
image_model: Optional[str] = Field(default=None, description="Image generation model")
|
||||
|
||||
# Video generation settings
|
||||
video_fps: int = Field(default=24, description="Frames per second for video")
|
||||
video_transition_duration: float = Field(default=0.5, description="Duration of transitions between scenes in seconds")
|
||||
|
||||
# Audio generation settings
|
||||
audio_provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
|
||||
audio_lang: str = Field(default="en", description="Language code for TTS")
|
||||
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
|
||||
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
|
||||
|
||||
|
||||
class StorySetupGenerationResponse(BaseModel):
|
||||
"""Response model for story setup generation."""
|
||||
options: List[StorySetupOption] = Field(..., description="Three story setup options")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
|
||||
|
||||
class StoryScene(BaseModel):
|
||||
"""Model for a story scene."""
|
||||
scene_number: int = Field(..., description="Scene number")
|
||||
title: str = Field(..., description="Scene title")
|
||||
description: str = Field(..., description="Scene description")
|
||||
image_prompt: str = Field(..., description="Image prompt for scene visualization")
|
||||
audio_narration: str = Field(..., description="Audio narration text for the scene")
|
||||
character_descriptions: List[str] = Field(default_factory=list, description="Character descriptions in the scene")
|
||||
key_events: List[str] = Field(default_factory=list, description="Key events in the scene")
|
||||
|
||||
|
||||
class StoryStartRequest(StoryGenerationRequest):
|
||||
"""Request model for story start generation."""
|
||||
premise: str = Field(..., description="The story premise")
|
||||
outline: Union[str, List[StoryScene], List[Dict[str, Any]]] = Field(..., description="The story outline (text or structured scenes)")
|
||||
|
||||
|
||||
class StoryPremiseResponse(BaseModel):
|
||||
"""Response model for premise generation."""
|
||||
premise: str = Field(..., description="Generated story premise")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
|
||||
|
||||
class StoryOutlineResponse(BaseModel):
|
||||
"""Response model for outline generation."""
|
||||
outline: Union[str, List[StoryScene]] = Field(..., description="Generated story outline (text or structured scenes)")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
is_structured: bool = Field(default=False, description="Whether the outline is structured (scenes) or plain text")
|
||||
|
||||
|
||||
class StoryContentResponse(BaseModel):
|
||||
"""Response model for story content generation."""
|
||||
story: str = Field(..., description="Generated story content")
|
||||
premise: Optional[str] = Field(None, description="Story premise")
|
||||
outline: Optional[str] = Field(None, description="Story outline")
|
||||
is_complete: bool = Field(default=False, description="Whether the story is complete")
|
||||
iterations: int = Field(default=0, description="Number of continuation iterations")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
|
||||
|
||||
class StoryFullGenerationResponse(BaseModel):
|
||||
"""Response model for full story generation."""
|
||||
premise: str = Field(..., description="Generated story premise")
|
||||
outline: str = Field(..., description="Generated story outline")
|
||||
story: str = Field(..., description="Generated complete story")
|
||||
is_complete: bool = Field(default=False, description="Whether the story is complete")
|
||||
iterations: int = Field(default=0, description="Number of continuation iterations")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
|
||||
|
||||
class StoryContinueRequest(BaseModel):
|
||||
"""Request model for continuing story generation."""
|
||||
premise: str = Field(..., description="The story premise")
|
||||
outline: Union[str, List[StoryScene], List[Dict[str, Any]]] = Field(..., description="The story outline (text or structured scenes)")
|
||||
story_text: str = Field(..., description="Current story text to continue from")
|
||||
persona: str = Field(..., description="The persona statement for the author")
|
||||
story_setting: str = Field(..., description="The setting of the story")
|
||||
character_input: str = Field(..., description="The characters in the story")
|
||||
plot_elements: str = Field(..., description="The plot elements of the story")
|
||||
writing_style: str = Field(..., description="The writing style")
|
||||
story_tone: str = Field(..., description="The tone of the story")
|
||||
narrative_pov: str = Field(..., description="The narrative point of view")
|
||||
audience_age_group: str = Field(..., description="The target audience age group")
|
||||
content_rating: str = Field(..., description="The content rating")
|
||||
ending_preference: str = Field(..., description="The preferred ending")
|
||||
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
|
||||
|
||||
|
||||
class StoryContinueResponse(BaseModel):
|
||||
"""Response model for story continuation."""
|
||||
continuation: str = Field(..., description="Generated story continuation")
|
||||
is_complete: bool = Field(default=False, description="Whether the story is complete (contains IAMDONE)")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
|
||||
|
||||
class TaskStatus(BaseModel):
|
||||
"""Task status model."""
|
||||
task_id: str = Field(..., description="Task ID")
|
||||
status: str = Field(..., description="Task status (pending, processing, completed, failed)")
|
||||
progress: Optional[float] = Field(None, description="Progress percentage (0-100)")
|
||||
message: Optional[str] = Field(None, description="Progress message")
|
||||
result: Optional[Dict[str, Any]] = Field(None, description="Task result when completed")
|
||||
error: Optional[str] = Field(None, description="Error message if failed")
|
||||
created_at: Optional[str] = Field(None, description="Task creation timestamp")
|
||||
updated_at: Optional[str] = Field(None, description="Task last update timestamp")
|
||||
|
||||
|
||||
class StoryImageGenerationRequest(BaseModel):
|
||||
"""Request model for image generation."""
|
||||
scenes: List[StoryScene] = Field(..., description="List of scenes to generate images for")
|
||||
provider: Optional[str] = Field(None, description="Image generation provider (gemini, huggingface, stability)")
|
||||
width: Optional[int] = Field(default=1024, description="Image width")
|
||||
height: Optional[int] = Field(default=1024, description="Image height")
|
||||
model: Optional[str] = Field(None, description="Image generation model")
|
||||
|
||||
|
||||
class StoryImageResult(BaseModel):
|
||||
"""Model for a generated image result."""
|
||||
scene_number: int = Field(..., description="Scene number")
|
||||
scene_title: str = Field(..., description="Scene title")
|
||||
image_filename: str = Field(..., description="Image filename")
|
||||
image_url: str = Field(..., description="Image URL")
|
||||
width: int = Field(..., description="Image width")
|
||||
height: int = Field(..., description="Image height")
|
||||
provider: str = Field(..., description="Image generation provider")
|
||||
model: Optional[str] = Field(None, description="Image generation model")
|
||||
seed: Optional[int] = Field(None, description="Image generation seed")
|
||||
error: Optional[str] = Field(None, description="Error message if generation failed")
|
||||
|
||||
|
||||
class StoryImageGenerationResponse(BaseModel):
|
||||
"""Response model for image generation."""
|
||||
images: List[StoryImageResult] = Field(..., description="List of generated images")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
|
||||
|
||||
class StoryAudioGenerationRequest(BaseModel):
|
||||
"""Request model for audio generation."""
|
||||
scenes: List[StoryScene] = Field(..., description="List of scenes to generate audio for")
|
||||
provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
|
||||
lang: Optional[str] = Field(default="en", description="Language code for TTS")
|
||||
slow: Optional[bool] = Field(default=False, description="Whether to speak slowly (gTTS only)")
|
||||
rate: Optional[int] = Field(default=150, description="Speech rate (pyttsx3 only)")
|
||||
|
||||
|
||||
class StoryAudioResult(BaseModel):
|
||||
"""Model for a generated audio result."""
|
||||
scene_number: int = Field(..., description="Scene number")
|
||||
scene_title: str = Field(..., description="Scene title")
|
||||
audio_filename: str = Field(..., description="Audio filename")
|
||||
audio_url: str = Field(..., description="Audio URL")
|
||||
provider: str = Field(..., description="TTS provider")
|
||||
file_size: int = Field(..., description="Audio file size in bytes")
|
||||
error: Optional[str] = Field(None, description="Error message if generation failed")
|
||||
|
||||
|
||||
class StoryAudioGenerationResponse(BaseModel):
|
||||
"""Response model for audio generation."""
|
||||
audio_files: List[StoryAudioResult] = Field(..., description="List of generated audio files")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
|
||||
|
||||
class StoryVideoGenerationRequest(BaseModel):
|
||||
"""Request model for video generation."""
|
||||
scenes: List[StoryScene] = Field(..., description="List of scenes to generate video for")
|
||||
image_urls: List[str] = Field(..., description="List of image URLs for each scene")
|
||||
audio_urls: List[str] = Field(..., description="List of audio URLs for each scene")
|
||||
story_title: Optional[str] = Field(default="Story", description="Title of the story")
|
||||
fps: Optional[int] = Field(default=24, description="Frames per second for video")
|
||||
transition_duration: Optional[float] = Field(default=0.5, description="Duration of transitions between scenes")
|
||||
|
||||
|
||||
class StoryVideoResult(BaseModel):
|
||||
"""Model for a generated video result."""
|
||||
video_filename: str = Field(..., description="Video filename")
|
||||
video_url: str = Field(..., description="Video URL")
|
||||
duration: float = Field(..., description="Video duration in seconds")
|
||||
fps: int = Field(..., description="Frames per second")
|
||||
file_size: int = Field(..., description="Video file size in bytes")
|
||||
num_scenes: int = Field(..., description="Number of scenes in the video")
|
||||
error: Optional[str] = Field(None, description="Error message if generation failed")
|
||||
|
||||
|
||||
class StoryVideoGenerationResponse(BaseModel):
|
||||
"""Response model for video generation."""
|
||||
video: StoryVideoResult = Field(..., description="Generated video")
|
||||
success: bool = Field(default=True, description="Whether the generation was successful")
|
||||
task_id: Optional[str] = Field(None, description="Task ID for async operations")
|
||||
@@ -323,4 +323,54 @@ class BillingHistory(Base):
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class SubscriptionRenewalHistory(Base):
|
||||
"""Historical record of subscription renewals and expiration events."""
|
||||
|
||||
__tablename__ = "subscription_renewal_history"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(String(100), nullable=False)
|
||||
|
||||
# Subscription Details
|
||||
plan_id = Column(Integer, ForeignKey('subscription_plans.id'), nullable=False)
|
||||
plan_name = Column(String(50), nullable=False)
|
||||
plan_tier = Column(String(20), nullable=False) # e.g., "free", "basic", "pro", "enterprise"
|
||||
|
||||
# Period Information
|
||||
previous_period_start = Column(DateTime, nullable=True) # Start of the previous period (if renewal)
|
||||
previous_period_end = Column(DateTime, nullable=True) # End of the previous period (when it expired)
|
||||
new_period_start = Column(DateTime, nullable=False) # Start of the new period (when renewed)
|
||||
new_period_end = Column(DateTime, nullable=False) # End of the new period
|
||||
|
||||
# Billing Cycle
|
||||
billing_cycle = Column(Enum(BillingCycle), nullable=False) # "monthly" or "yearly"
|
||||
|
||||
# Renewal Information
|
||||
renewal_type = Column(String(20), nullable=False) # "new", "renewal", "upgrade", "downgrade"
|
||||
renewal_count = Column(Integer, default=0) # Sequential renewal number (1st renewal, 2nd renewal, etc.)
|
||||
|
||||
# Previous Subscription Snapshot (before renewal)
|
||||
previous_plan_name = Column(String(50), nullable=True)
|
||||
previous_plan_tier = Column(String(20), nullable=True)
|
||||
|
||||
# Usage Summary Before Renewal (snapshot)
|
||||
usage_before_renewal = Column(JSON, nullable=True) # Snapshot of usage before renewal
|
||||
|
||||
# Payment Information
|
||||
payment_amount = Column(Float, default=0.0)
|
||||
payment_status = Column(String(20), default="pending") # "pending", "paid", "failed"
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
stripe_invoice_id = Column(String(100), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
plan = relationship("SubscriptionPlan")
|
||||
|
||||
# Indexes for performance
|
||||
__table_args__ = (
|
||||
{'mysql_engine': 'InnoDB'},
|
||||
)
|
||||
@@ -28,7 +28,7 @@ class WebsiteAnalysisTask(Base):
|
||||
competitor_id = Column(String(255), nullable=True) # For competitor tasks (domain or identifier)
|
||||
|
||||
# Task Status
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused'
|
||||
status = Column(String(50), default='active') # 'active', 'failed', 'paused', 'needs_intervention'
|
||||
|
||||
# Execution Tracking
|
||||
last_check = Column(DateTime, nullable=True)
|
||||
@@ -36,6 +36,10 @@ class WebsiteAnalysisTask(Base):
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
# Failure Pattern Tracking
|
||||
consecutive_failures = Column(Integer, default=0) # Count of consecutive failures
|
||||
failure_pattern = Column(JSON, nullable=True) # JSON storing failure analysis
|
||||
|
||||
# Scheduling
|
||||
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
|
||||
frequency_days = Column(Integer, default=10) # Recurring frequency in days
|
||||
|
||||
@@ -56,6 +56,15 @@ Pillow>=10.0.0
|
||||
huggingface_hub>=0.24.0
|
||||
scikit-learn>=1.3.0
|
||||
|
||||
# Text-to-Speech (TTS) dependencies
|
||||
gtts>=2.4.0
|
||||
pyttsx3>=2.90
|
||||
|
||||
# Video composition dependencies
|
||||
moviepy>=1.0.3
|
||||
imageio>=2.31.0
|
||||
imageio-ffmpeg>=0.4.9
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
|
||||
143
backend/scripts/check_wix_config.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Quick diagnostic script to check Wix configuration.
|
||||
|
||||
Run this to verify your WIX_API_KEY is properly loaded.
|
||||
|
||||
Usage:
|
||||
python backend/scripts/check_wix_config.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
def check_wix_config():
|
||||
"""Check if Wix configuration is properly set up."""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🔍 WIX CONFIGURATION DIAGNOSTIC")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# 1. Check if .env file exists
|
||||
env_locations = [
|
||||
Path.cwd() / ".env",
|
||||
Path.cwd() / "backend" / ".env",
|
||||
Path.cwd() / ".env.local",
|
||||
]
|
||||
|
||||
print("📁 Checking for .env files:")
|
||||
env_file_found = False
|
||||
for env_path in env_locations:
|
||||
exists = env_path.exists()
|
||||
status = "✅ FOUND" if exists else "❌ NOT FOUND"
|
||||
print(f" {status}: {env_path}")
|
||||
if exists:
|
||||
env_file_found = True
|
||||
|
||||
if not env_file_found:
|
||||
print("\n⚠️ WARNING: No .env file found!")
|
||||
print(" Create a .env file in your project root.")
|
||||
|
||||
print("\n" + "-"*60 + "\n")
|
||||
|
||||
# 2. Try loading .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
print("✅ dotenv loaded successfully")
|
||||
except ImportError:
|
||||
print("❌ python-dotenv not installed")
|
||||
print(" Install: pip install python-dotenv")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading .env: {e}")
|
||||
|
||||
print("\n" + "-"*60 + "\n")
|
||||
|
||||
# 3. Check WIX_API_KEY environment variable
|
||||
print("🔑 Checking WIX_API_KEY environment variable:")
|
||||
api_key = os.getenv('WIX_API_KEY')
|
||||
|
||||
if not api_key:
|
||||
print(" ❌ NOT FOUND")
|
||||
print("\n⚠️ CRITICAL: WIX_API_KEY is not set!")
|
||||
print("\nTo fix:")
|
||||
print(" 1. Add this line to your .env file:")
|
||||
print(" WIX_API_KEY=your_api_key_from_wix_dashboard")
|
||||
print(" 2. Restart your backend server")
|
||||
print(" 3. Run this script again to verify")
|
||||
return False
|
||||
|
||||
print(" ✅ FOUND")
|
||||
print(f" Length: {len(api_key)} characters")
|
||||
print(f" Preview: {api_key[:30]}...")
|
||||
|
||||
# 4. Validate API key format
|
||||
print("\n" + "-"*60 + "\n")
|
||||
print("🔍 Validating API key format:")
|
||||
|
||||
if api_key.startswith("JWS."):
|
||||
print(" ✅ Starts with 'JWS.' (correct format)")
|
||||
else:
|
||||
print(f" ⚠️ Doesn't start with 'JWS.' (got: {api_key[:10]}...)")
|
||||
print(" This might not be a valid Wix API key")
|
||||
|
||||
if len(api_key) > 200:
|
||||
print(f" ✅ Length looks correct ({len(api_key)} chars)")
|
||||
else:
|
||||
print(f" ⚠️ API key seems too short ({len(api_key)} chars)")
|
||||
print(" Wix API keys are typically 500+ characters")
|
||||
|
||||
dot_count = api_key.count('.')
|
||||
print(f" 📊 Contains {dot_count} dots (JWT tokens have 2+ dots)")
|
||||
|
||||
# 5. Test import of Wix services
|
||||
print("\n" + "-"*60 + "\n")
|
||||
print("📦 Testing Wix service imports:")
|
||||
|
||||
try:
|
||||
from services.integrations.wix.auth_utils import get_wix_api_key
|
||||
test_key = get_wix_api_key()
|
||||
|
||||
if test_key:
|
||||
print(" ✅ auth_utils.get_wix_api_key() works")
|
||||
print(f" ✅ Returned key length: {len(test_key)}")
|
||||
print(f" ✅ Keys match: {test_key == api_key}")
|
||||
else:
|
||||
print(" ❌ auth_utils.get_wix_api_key() returned None")
|
||||
print(" Even though os.getenv('WIX_API_KEY') found it!")
|
||||
print(" This indicates an environment loading issue.")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error importing: {e}")
|
||||
|
||||
# 6. Final summary
|
||||
print("\n" + "="*60)
|
||||
print("📋 SUMMARY")
|
||||
print("="*60 + "\n")
|
||||
|
||||
if api_key and len(api_key) > 200 and api_key.startswith("JWS."):
|
||||
print("✅ Configuration looks GOOD!")
|
||||
print("\nNext steps:")
|
||||
print(" 1. Restart your backend server")
|
||||
print(" 2. Try publishing a blog post")
|
||||
print(" 3. Check logs for 'Using API key' messages")
|
||||
print(" 4. Verify no 403 Forbidden errors")
|
||||
else:
|
||||
print("❌ Configuration has ISSUES!")
|
||||
print("\nPlease review the warnings above and:")
|
||||
print(" 1. Ensure WIX_API_KEY is set in your .env file")
|
||||
print(" 2. Verify the API key is correct (from Wix Dashboard)")
|
||||
print(" 3. Restart your backend server")
|
||||
print(" 4. Run this script again")
|
||||
|
||||
print("\n" + "="*60 + "\n")
|
||||
|
||||
return bool(api_key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = check_wix_config()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
85
backend/scripts/run_failure_tracking_migration.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Script to run the failure tracking migration.
|
||||
Adds consecutive_failures and failure_pattern columns to task tables.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path to import migration
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
def run_migration():
|
||||
"""Run the failure tracking migration."""
|
||||
# Get database path
|
||||
db_path = os.getenv('DATABASE_URL', 'sqlite:///alwrity.db')
|
||||
|
||||
# Extract path from SQLite URL if needed
|
||||
if db_path.startswith('sqlite:///'):
|
||||
db_path = db_path.replace('sqlite:///', '')
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found at {db_path}")
|
||||
return False
|
||||
|
||||
print(f"Running migration on database: {db_path}")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Read migration SQL
|
||||
migration_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'database',
|
||||
'migrations',
|
||||
'add_failure_tracking_to_tasks.sql'
|
||||
)
|
||||
|
||||
if not os.path.exists(migration_file):
|
||||
print(f"Migration file not found: {migration_file}")
|
||||
return False
|
||||
|
||||
with open(migration_file, 'r') as f:
|
||||
migration_sql = f.read()
|
||||
|
||||
# Execute migration (SQLite doesn't support multiple statements in execute, so split)
|
||||
statements = [s.strip() for s in migration_sql.split(';') if s.strip()]
|
||||
|
||||
for statement in statements:
|
||||
try:
|
||||
cursor.execute(statement)
|
||||
print(f"✓ Executed: {statement[:50]}...")
|
||||
except sqlite3.OperationalError as e:
|
||||
# Column might already exist - that's okay
|
||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
||||
print(f"⚠ Column already exists (skipping): {statement[:50]}...")
|
||||
else:
|
||||
raise
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ Migration completed successfully!")
|
||||
|
||||
# Verify columns were added
|
||||
cursor.execute("PRAGMA table_info(oauth_token_monitoring_tasks)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
if 'consecutive_failures' in columns and 'failure_pattern' in columns:
|
||||
print("✓ Verified: consecutive_failures and failure_pattern columns exist")
|
||||
else:
|
||||
print("⚠ Warning: Could not verify columns were added")
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error running migration: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_migration()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
186
backend/services/blog_writer/content/introduction_generator.py
Normal 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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
198
backend/services/blog_writer/outline/seo_title_generator.py
Normal 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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
132
backend/services/integrations/wix/auth_utils.py
Normal 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
118
backend/services/integrations/wix/logger.py
Normal 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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
378
backend/services/scheduler/core/failure_detection_service.py
Normal 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 []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
96
backend/services/story_writer/README.md
Normal 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.
|
||||
10
backend/services/story_writer/__init__.py
Normal 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']
|
||||
291
backend/services/story_writer/audio_generation_service.py
Normal 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
|
||||
|
||||
196
backend/services/story_writer/image_generation_service.py
Normal 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
|
||||
|
||||
14
backend/services/story_writer/service_components/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
332
backend/services/story_writer/service_components/base.py
Normal 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)}")
|
||||
|
||||
171
backend/services/story_writer/service_components/outline.py
Normal 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
|
||||
|
||||
273
backend/services/story_writer/service_components/setup.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
30
backend/services/story_writer/story_service.py
Normal 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__ = ()
|
||||
294
backend/services/story_writer/video_generation_service.py
Normal 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
|
||||
|
||||
231
backend/services/subscription/log_wrapping_service.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 1020 KiB |
BIN
backend/story_images/scene_4_Gravity_s_Gentle_Pull_382cd57c.png
Normal file
|
After Width: | Height: | Size: 1017 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |