From 3b9356e2c83da9828899f42a4116d27d07545b32 Mon Sep 17 00:00:00 2001
From: ajaysi
Date: Thu, 13 Nov 2025 16:14:26 +0530
Subject: [PATCH] story writer backend migration complete, Blog writer SEO and
story writer backend migration complete, Blog writer SEO and story writer
frontend migration complete
---
backend/alwrity_utils/router_manager.py | 7 +
backend/api/blog_writer/router.py | 118 ++
backend/api/scheduler_dashboard.py | 112 ++
backend/api/story_writer/__init__.py | 9 +
backend/api/story_writer/cache_manager.py | 70 +
backend/api/story_writer/router.py | 1181 +++++++++++++++++
backend/api/story_writer/task_manager.py | 251 ++++
backend/api/subscription_api.py | 311 ++++-
backend/api/wix_routes.py | 24 +-
backend/app.py | 17 +-
.../models/oauth_token_monitoring_models.py | 6 +-
.../platform_insights_monitoring_models.py | 6 +-
backend/models/story_models.py | 262 ++++
backend/models/subscription_models.py | 52 +-
.../website_analysis_monitoring_models.py | 6 +-
backend/requirements.txt | 9 +
backend/scripts/check_wix_config.py | 143 ++
.../scripts/run_failure_tracking_migration.py | 85 ++
.../content/introduction_generator.py | 186 +++
.../blog_writer/outline/prompt_builder.py | 70 +-
.../outline/seo_title_generator.py | 198 +++
.../blog_writer/research/research_service.py | 51 +-
backend/services/integrations/wix/auth.py | 6 +-
.../services/integrations/wix/auth_utils.py | 132 ++
backend/services/integrations/wix/blog.py | 89 +-
.../integrations/wix/blog_publisher.py | 352 ++---
backend/services/integrations/wix/content.py | 72 +-
backend/services/integrations/wix/logger.py | 118 ++
.../integrations/wix/ricos_converter.py | 25 +
backend/services/integrations/wix/seo.py | 25 +-
.../core/failure_detection_service.py | 378 ++++++
.../scheduler/core/task_execution_handler.py | 16 +-
.../executors/bing_insights_executor.py | 66 +-
.../executors/gsc_insights_executor.py | 66 +-
.../oauth_token_monitoring_executor.py | 41 +-
.../executors/website_analysis_executor.py | 40 +-
backend/services/story_writer/README.md | 96 ++
backend/services/story_writer/__init__.py | 10 +
.../story_writer/audio_generation_service.py | 291 ++++
.../story_writer/image_generation_service.py | 196 +++
.../service_components/__init__.py | 14 +
.../story_writer/service_components/base.py | 332 +++++
.../service_components/outline.py | 171 +++
.../story_writer/service_components/setup.py | 273 ++++
.../service_components/story_content.py | 428 ++++++
.../services/story_writer/story_service.py | 30 +
.../story_writer/video_generation_service.py | 294 ++++
.../subscription/log_wrapping_service.py | 231 ++++
.../services/subscription/pricing_service.py | 126 +-
.../subscription/usage_tracking_service.py | 123 +-
backend/services/wix_service.py | 25 +-
...elcome_to_the_Fluffy_Cloud_Ki_6818cab1.png | Bin 0 -> 1209263 bytes
...eeting_Spark_the_Silver_Spoon_c3c1f32a.png | Bin 0 -> 989947 bytes
...athering_Space_Dust_and_Wishe_85bbcf02.png | Bin 0 -> 1044464 bytes
...scene_4_Gravity_s_Gentle_Pull_382cd57c.png | Bin 0 -> 1041271 bytes
..._5_The_Mixture_Starts_to_Glow_4cdecd01.png | Bin 0 -> 1075382 bytes
...ene_6_The_Birth_of_a_New_Star_d68c6f67.png | Bin 0 -> 1149701 bytes
...elebration_and_Sweet_Goodbyes_3a3373a2.png | Bin 0 -> 1109008 bytes
.../HUGGINGFACE_PRICING.md | 103 ++
.../STORY_GENERATION_CODE_ADAPTATION_GUIDE.md | 499 +++++++
docs/STORY_GENERATION_IMPLEMENTATION_PLAN.md | 537 ++++++++
docs/STORY_GENERATION_READINESS_ASSESSMENT.md | 157 +++
...STORY_WRITER_BACKEND_MIGRATION_COMPLETE.md | 137 ++
...ORY_WRITER_FRONTEND_FOUNDATION_COMPLETE.md | 204 +++
docs/STORY_WRITER_IMPLEMENTATION_REVIEW.md | 405 ++++++
docs/STORY_WRITER_NEXT_STEPS.md | 312 +++++
docs/STORY_WRITER_REVIEW_AND_NEXT_STEPS.md | 436 ++++++
docs/STORY_WRITER_TESTING_GUIDE.md | 424 ++++++
frontend/src/App.tsx | 36 +-
frontend/src/api/client.ts | 12 +-
.../src/components/BlogWriter/BlogWriter.tsx | 9 +-
.../BlogWriterUtils/PhaseContent.tsx | 1 +
.../BlogWriterUtils/WixConnectModal.tsx | 27 +-
.../BlogWriterUtils/useSEOManager.ts | 304 ++++-
.../BlogWriter/EnhancedOutlineEditor.tsx | 217 ++-
.../BlogWriter/EnhancedTitleSelector.tsx | 381 ++++--
.../components/BlogWriter/ResearchAction.tsx | 60 +-
.../BlogWriter/ResearchProgressModal.tsx | 641 ++++++++-
.../BlogWriter/SEOAnalysisModal.tsx | 104 +-
.../BlogWriter/SEOMetadataModal.tsx | 160 ++-
.../BlogWriter/SuggestionsGenerator.tsx | 24 +-
.../BlogWriter/WYSIWYG/BlogEditor.tsx | 207 ++-
.../MainDashboard/MainDashboard.tsx | 2 +-
.../common/usePlatformConnections.ts | 3 +-
.../TasksNeedingIntervention.tsx | 430 ++++++
.../StoryWriter/PhaseNavigation.tsx | 122 ++
.../StoryWriter/Phases/StoryExport.tsx | 360 +++++
.../StoryWriter/Phases/StoryOutline.tsx | 970 ++++++++++++++
.../StoryWriter/Phases/StoryPremise.tsx | 111 ++
.../Phases/StorySetup/AIStorySetupModal.tsx | 499 +++++++
.../StorySetup/FeatureCheckboxesSection.tsx | 43 +
.../StorySetup/FormFieldWithTooltip.tsx | 96 ++
.../StorySetup/GenerationSettingsSection.tsx | 245 ++++
.../StorySetup/SelectFieldWithTooltip.tsx | 94 ++
.../StorySetup/StoryConfigurationSection.tsx | 191 +++
.../StorySetup/StoryParametersSection.tsx | 151 +++
.../Phases/StorySetup/constants.ts | 79 ++
.../StoryWriter/Phases/StorySetup/index.tsx | 257 ++++
.../StoryWriter/Phases/StorySetup/styles.ts | 82 ++
.../StoryWriter/Phases/StorySetup/types.ts | 33 +
.../StoryWriter/Phases/StoryWriting.tsx | 292 ++++
.../components/StoryWriter/StoryWriter.tsx | 120 ++
frontend/src/components/StoryWriter/index.ts | 2 +
.../WixCallbackPage/WixCallbackPage.tsx | 41 +-
.../components/billing/BillingOverview.tsx | 264 ++--
.../billing/CompactBillingDashboard.tsx | 731 ++++++----
.../billing/EnhancedBillingDashboard.tsx | 73 +-
.../billing/SubscriptionRenewalHistory.tsx | 467 +++++++
.../src/components/billing/UsageLogsTable.tsx | 426 ++++++
frontend/src/contexts/SubscriptionContext.tsx | 70 +-
frontend/src/hooks/useBlogWriterState.ts | 126 +-
frontend/src/hooks/useOAuthTokenAlerts.ts | 55 +-
frontend/src/hooks/useSchedulerTaskAlerts.ts | 156 +++
.../hooks/useStoryWriterPhaseNavigation.ts | 184 +++
frontend/src/hooks/useStoryWriterState.ts | 455 +++++++
frontend/src/pages/BillingPage.tsx | 222 ++++
frontend/src/pages/SchedulerDashboard.tsx | 17 +-
frontend/src/services/billingService.ts | 336 +++--
frontend/src/services/blogWriterApi.ts | 25 +
frontend/src/services/monitoringService.ts | 8 +-
frontend/src/services/storyWriterApi.ts | 446 +++++++
frontend/src/types/billing.ts | 104 +-
frontend/src/utils/toastNotifications.ts | 147 ++
scripts/wix_reconsent_helper.py | 91 ++
124 files changed, 20055 insertions(+), 1208 deletions(-)
create mode 100644 backend/api/story_writer/__init__.py
create mode 100644 backend/api/story_writer/cache_manager.py
create mode 100644 backend/api/story_writer/router.py
create mode 100644 backend/api/story_writer/task_manager.py
create mode 100644 backend/models/story_models.py
create mode 100644 backend/scripts/check_wix_config.py
create mode 100644 backend/scripts/run_failure_tracking_migration.py
create mode 100644 backend/services/blog_writer/content/introduction_generator.py
create mode 100644 backend/services/blog_writer/outline/seo_title_generator.py
create mode 100644 backend/services/integrations/wix/auth_utils.py
create mode 100644 backend/services/integrations/wix/logger.py
create mode 100644 backend/services/scheduler/core/failure_detection_service.py
create mode 100644 backend/services/story_writer/README.md
create mode 100644 backend/services/story_writer/__init__.py
create mode 100644 backend/services/story_writer/audio_generation_service.py
create mode 100644 backend/services/story_writer/image_generation_service.py
create mode 100644 backend/services/story_writer/service_components/__init__.py
create mode 100644 backend/services/story_writer/service_components/base.py
create mode 100644 backend/services/story_writer/service_components/outline.py
create mode 100644 backend/services/story_writer/service_components/setup.py
create mode 100644 backend/services/story_writer/service_components/story_content.py
create mode 100644 backend/services/story_writer/story_service.py
create mode 100644 backend/services/story_writer/video_generation_service.py
create mode 100644 backend/services/subscription/log_wrapping_service.py
create mode 100644 backend/story_images/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_6818cab1.png
create mode 100644 backend/story_images/scene_2_Meeting_Spark_the_Silver_Spoon_c3c1f32a.png
create mode 100644 backend/story_images/scene_3_Gathering_Space_Dust_and_Wishe_85bbcf02.png
create mode 100644 backend/story_images/scene_4_Gravity_s_Gentle_Pull_382cd57c.png
create mode 100644 backend/story_images/scene_5_The_Mixture_Starts_to_Glow_4cdecd01.png
create mode 100644 backend/story_images/scene_6_The_Birth_of_a_New_Star_d68c6f67.png
create mode 100644 backend/story_images/scene_7_Celebration_and_Sweet_Goodbyes_3a3373a2.png
create mode 100644 docs/Billing_Subscription/HUGGINGFACE_PRICING.md
create mode 100644 docs/STORY_GENERATION_CODE_ADAPTATION_GUIDE.md
create mode 100644 docs/STORY_GENERATION_IMPLEMENTATION_PLAN.md
create mode 100644 docs/STORY_GENERATION_READINESS_ASSESSMENT.md
create mode 100644 docs/STORY_WRITER_BACKEND_MIGRATION_COMPLETE.md
create mode 100644 docs/STORY_WRITER_FRONTEND_FOUNDATION_COMPLETE.md
create mode 100644 docs/STORY_WRITER_IMPLEMENTATION_REVIEW.md
create mode 100644 docs/STORY_WRITER_NEXT_STEPS.md
create mode 100644 docs/STORY_WRITER_REVIEW_AND_NEXT_STEPS.md
create mode 100644 docs/STORY_WRITER_TESTING_GUIDE.md
create mode 100644 frontend/src/components/SchedulerDashboard/TasksNeedingIntervention.tsx
create mode 100644 frontend/src/components/StoryWriter/PhaseNavigation.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StoryExport.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/AIStorySetupModal.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/FeatureCheckboxesSection.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/FormFieldWithTooltip.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/GenerationSettingsSection.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/SelectFieldWithTooltip.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/StoryConfigurationSection.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/StoryParametersSection.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/constants.ts
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/styles.ts
create mode 100644 frontend/src/components/StoryWriter/Phases/StorySetup/types.ts
create mode 100644 frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
create mode 100644 frontend/src/components/StoryWriter/StoryWriter.tsx
create mode 100644 frontend/src/components/StoryWriter/index.ts
create mode 100644 frontend/src/components/billing/SubscriptionRenewalHistory.tsx
create mode 100644 frontend/src/components/billing/UsageLogsTable.tsx
create mode 100644 frontend/src/hooks/useSchedulerTaskAlerts.ts
create mode 100644 frontend/src/hooks/useStoryWriterPhaseNavigation.ts
create mode 100644 frontend/src/hooks/useStoryWriterState.ts
create mode 100644 frontend/src/pages/BillingPage.tsx
create mode 100644 frontend/src/services/storyWriterApi.ts
create mode 100644 frontend/src/utils/toastNotifications.ts
create mode 100644 scripts/wix_reconsent_helper.py
diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py
index 454bb5f6..608ffc3c 100644
--- a/backend/alwrity_utils/router_manager.py
+++ b/backend/alwrity_utils/router_manager.py
@@ -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
diff --git a/backend/api/blog_writer/router.py b/backend/api/blog_writer/router.py
index 12c6a72e..2fe8998d 100644
--- a/backend/api/blog_writer/router.py
+++ b/backend/api/blog_writer/router.py
@@ -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))
\ No newline at end of file
diff --git a/backend/api/scheduler_dashboard.py b/backend/api/scheduler_dashboard.py
index 967778ce..e8ff9d5f 100644
--- a/backend/api/scheduler_dashboard.py
+++ b/backend/api/scheduler_dashboard.py
@@ -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,
diff --git a/backend/api/story_writer/__init__.py b/backend/api/story_writer/__init__.py
new file mode 100644
index 00000000..dbd75aab
--- /dev/null
+++ b/backend/api/story_writer/__init__.py
@@ -0,0 +1,9 @@
+"""
+Story Writer API
+
+API endpoints for story generation functionality.
+"""
+
+from .router import router
+
+__all__ = ['router']
diff --git a/backend/api/story_writer/cache_manager.py b/backend/api/story_writer/cache_manager.py
new file mode 100644
index 00000000..c0db5fe1
--- /dev/null
+++ b/backend/api/story_writer/cache_manager.py
@@ -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()
diff --git a/backend/api/story_writer/router.py b/backend/api/story_writer/router.py
new file mode 100644
index 00000000..cabcc9f4
--- /dev/null
+++ b/backend/api/story_writer/router.py
@@ -0,0 +1,1181 @@
+"""
+Story Writer API Router
+
+Main router for story generation operations including premise, outline,
+content generation, and full story creation.
+"""
+
+from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
+from typing import Any, Dict, Union, List
+from loguru import logger
+from middleware.auth_middleware import get_current_user
+
+from models.story_models import (
+ StoryGenerationRequest,
+ StorySetupGenerationRequest,
+ StorySetupGenerationResponse,
+ StorySetupOption,
+ StoryStartRequest,
+ StoryPremiseResponse,
+ StoryOutlineResponse,
+ StoryScene,
+ StoryContentResponse,
+ StoryFullGenerationResponse,
+ StoryContinueRequest,
+ StoryContinueResponse,
+ StoryImageGenerationRequest,
+ StoryImageGenerationResponse,
+ StoryImageResult,
+ StoryAudioGenerationRequest,
+ StoryAudioGenerationResponse,
+ StoryAudioResult,
+ StoryVideoGenerationRequest,
+ StoryVideoGenerationResponse,
+ StoryVideoResult,
+ TaskStatus,
+)
+from services.story_writer.story_service import StoryWriterService
+from .task_manager import task_manager
+from .cache_manager import cache_manager
+
+
+router = APIRouter(prefix="/api/story", tags=["Story Writer"])
+
+service = StoryWriterService()
+
+
+@router.get("/health")
+async def health() -> Dict[str, Any]:
+ """Health check endpoint."""
+ return {"status": "ok", "service": "story_writer"}
+
+
+# ---------------------------
+# Story Setup Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-setup", response_model=StorySetupGenerationResponse)
+async def generate_story_setup(
+ request: StorySetupGenerationRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StorySetupGenerationResponse:
+ """Generate 3 story setup options from a user's story idea."""
+ try:
+ 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")
+
+ if not request.story_idea or not request.story_idea.strip():
+ raise HTTPException(status_code=400, detail="Story idea is required")
+
+ logger.info(f"[StoryWriter] Generating story setup options for user {user_id}")
+
+ options = service.generate_story_setup_options(
+ story_idea=request.story_idea,
+ user_id=user_id
+ )
+
+ # Convert dict options to StorySetupOption models
+ setup_options = [StorySetupOption(**option) for option in options]
+
+ return StorySetupGenerationResponse(options=setup_options, success=True)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate story setup options: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Premise Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-premise", response_model=StoryPremiseResponse)
+async def generate_premise(
+ request: StoryGenerationRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryPremiseResponse:
+ """Generate a story premise."""
+ try:
+ 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")
+
+ logger.info(f"[StoryWriter] Generating premise for user {user_id}")
+
+ premise = service.generate_premise(
+ persona=request.persona,
+ story_setting=request.story_setting,
+ character_input=request.character_input,
+ plot_elements=request.plot_elements,
+ writing_style=request.writing_style,
+ story_tone=request.story_tone,
+ narrative_pov=request.narrative_pov,
+ audience_age_group=request.audience_age_group,
+ content_rating=request.content_rating,
+ ending_preference=request.ending_preference,
+ user_id=user_id
+ )
+
+ return StoryPremiseResponse(premise=premise, success=True)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate premise: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Outline Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-outline", response_model=StoryOutlineResponse)
+async def generate_outline(
+ request: StoryStartRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user),
+ use_structured: bool = True
+) -> StoryOutlineResponse:
+ """Generate a story outline from a premise."""
+ try:
+ 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")
+
+ if not request.premise or not request.premise.strip():
+ raise HTTPException(status_code=400, detail="Premise is required")
+
+ logger.info(f"[StoryWriter] Generating outline for user {user_id} (structured={use_structured})")
+ logger.info(f"[StoryWriter] Outline generation parameters: audience_age_group={request.audience_age_group}, writing_style={request.writing_style}, story_tone={request.story_tone}")
+
+ outline = service.generate_outline(
+ premise=request.premise,
+ persona=request.persona,
+ story_setting=request.story_setting,
+ character_input=request.character_input,
+ plot_elements=request.plot_elements,
+ writing_style=request.writing_style,
+ story_tone=request.story_tone,
+ narrative_pov=request.narrative_pov,
+ audience_age_group=request.audience_age_group,
+ content_rating=request.content_rating,
+ ending_preference=request.ending_preference,
+ user_id=user_id,
+ use_structured_output=use_structured
+ )
+
+ # Check if outline is structured (list of scenes) or plain text
+ is_structured = isinstance(outline, list)
+
+ if is_structured:
+ # Convert dict scenes to StoryScene models
+ scenes = [StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline]
+ return StoryOutlineResponse(outline=scenes, success=True, is_structured=True)
+ else:
+ # Plain text outline
+ return StoryOutlineResponse(outline=str(outline), success=True, is_structured=False)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate outline: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Story Content Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-start", response_model=StoryContentResponse)
+async def generate_story_start(
+ request: StoryStartRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryContentResponse:
+ """Generate the starting section of a story."""
+ try:
+ 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")
+
+ if not request.premise or not request.premise.strip():
+ raise HTTPException(status_code=400, detail="Premise is required")
+ if not request.outline or (isinstance(request.outline, str) and not request.outline.strip()):
+ raise HTTPException(status_code=400, detail="Outline is required")
+
+ logger.info(f"[StoryWriter] Generating story start for user {user_id}")
+
+ # Handle outline - could be string or list (structured scenes)
+ outline_data = request.outline
+ # Convert StoryScene models to dicts if needed
+ if isinstance(outline_data, list) and len(outline_data) > 0:
+ if isinstance(outline_data[0], StoryScene):
+ outline_data = [scene.dict() for scene in outline_data]
+
+ story_length = getattr(request, 'story_length', 'Medium')
+ story_start = service.generate_story_start(
+ premise=request.premise,
+ outline=outline_data,
+ persona=request.persona,
+ story_setting=request.story_setting,
+ character_input=request.character_input,
+ plot_elements=request.plot_elements,
+ writing_style=request.writing_style,
+ story_tone=request.story_tone,
+ narrative_pov=request.narrative_pov,
+ audience_age_group=request.audience_age_group,
+ content_rating=request.content_rating,
+ ending_preference=request.ending_preference,
+ story_length=story_length,
+ user_id=user_id
+ )
+
+ # Check if this is a short story - if so, mark as complete immediately
+ story_length_lower = story_length.lower()
+ is_short_story = "short" in story_length_lower or "1000" in story_length_lower
+
+ # For short stories, check word count to verify completeness
+ is_complete = False
+ if is_short_story:
+ word_count = len(story_start.split()) if story_start else 0
+ # Short story should be ~1000 words (900-1100 acceptable range)
+ if word_count >= 900:
+ is_complete = True
+ logger.info(f"[StoryWriter] Short story generated with {word_count} words. Marking as complete.")
+ else:
+ logger.warning(f"[StoryWriter] Short story generated with only {word_count} words. May need continuation.")
+
+ # Format outline for response (convert list to string if needed)
+ outline_response = outline_data
+ if isinstance(outline_data, list):
+ # Format structured outline as readable text
+ outline_response = "\n".join([
+ f"Scene {scene.get('scene_number', i+1) if isinstance(scene, dict) else getattr(scene, 'scene_number', i+1)}: "
+ f"{scene.get('title', 'Untitled') if isinstance(scene, dict) else getattr(scene, 'title', 'Untitled')}\n"
+ f" {scene.get('description', '') if isinstance(scene, dict) else getattr(scene, 'description', '')}"
+ for i, scene in enumerate(outline_data)
+ ])
+
+ return StoryContentResponse(
+ story=story_start,
+ premise=request.premise,
+ outline=str(outline_response),
+ is_complete=is_complete, # True for short stories that are complete, False for medium/long
+ success=True
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate story start: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/continue", response_model=StoryContinueResponse)
+async def continue_story(
+ request: StoryContinueRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryContinueResponse:
+ """Continue writing a story."""
+ try:
+ 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")
+
+ if not request.story_text or not request.story_text.strip():
+ raise HTTPException(status_code=400, detail="Story text is required")
+
+ logger.info(f"[StoryWriter] Continuing story for user {user_id}")
+
+ # Handle outline - could be string or list (structured scenes)
+ outline_data = request.outline
+ # Convert StoryScene models to dicts if needed
+ if isinstance(outline_data, list) and len(outline_data) > 0:
+ if isinstance(outline_data[0], StoryScene):
+ outline_data = [scene.dict() for scene in outline_data]
+
+ # Check word count before continuing
+ story_length = getattr(request, 'story_length', 'Medium')
+ story_length_lower = story_length.lower()
+ is_short_story = "short" in story_length_lower or "1000" in story_length_lower
+
+ # Block continuation for short stories - they should be complete in one call
+ if is_short_story:
+ logger.warning(f"[StoryWriter] Attempted to continue a short story. Short stories should be complete in one call.")
+ raise HTTPException(
+ status_code=400,
+ detail="Short stories are generated in a single call and should be complete. If the story is incomplete, please regenerate it from the beginning."
+ )
+
+ current_word_count = len(request.story_text.split()) if request.story_text else 0
+
+ # Determine target word count based on story length (with 5% buffer)
+ # Medium: <5000 words (target ~4500, buffer ~4725)
+ # Long: around 10000 words (target ~10000, buffer ~10500)
+ if "long" in story_length_lower or "10000" in story_length_lower:
+ target_total_words = 10000
+ buffer_target = int(10000 * 1.05) # 10500 words maximum
+ else:
+ # Medium story: <5000 words
+ target_total_words = 4500 # Target for medium stories
+ buffer_target = int(4500 * 1.05) # ~4725 words maximum
+
+ # If target is already reached or exceeded, return completion immediately
+ if current_word_count >= buffer_target:
+ logger.info(f"[StoryWriter] Word count ({current_word_count}) already at or past buffer target ({buffer_target}) for {story_length} story. Story is complete.")
+ return StoryContinueResponse(
+ continuation="IAMDONE",
+ is_complete=True,
+ success=True
+ )
+
+ # Also check if we're very close to target (within 50 words)
+ 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 StoryContinueResponse(
+ continuation="IAMDONE",
+ is_complete=True,
+ success=True
+ )
+
+ continuation = service.continue_story(
+ premise=request.premise,
+ outline=outline_data,
+ story_text=request.story_text,
+ persona=request.persona,
+ story_setting=request.story_setting,
+ character_input=request.character_input,
+ plot_elements=request.plot_elements,
+ writing_style=request.writing_style,
+ story_tone=request.story_tone,
+ narrative_pov=request.narrative_pov,
+ audience_age_group=request.audience_age_group,
+ content_rating=request.content_rating,
+ ending_preference=request.ending_preference,
+ story_length=story_length,
+ user_id=user_id
+ )
+
+ # Check if continuation is IAMDONE or if word count now exceeds target
+ is_complete = 'IAMDONE' in continuation.upper()
+
+ # Also check word count after continuation
+ if not is_complete and continuation:
+ # Estimate new word count
+ new_story_text = request.story_text + '\n\n' + continuation
+ new_word_count = len(new_story_text.split())
+
+ # Calculate buffer target
+ buffer_target = int(target_total_words * 1.05)
+
+ # If new word count exceeds buffer target, mark as complete
+ if new_word_count >= buffer_target:
+ logger.info(f"[StoryWriter] Word count ({new_word_count}) now exceeds buffer target ({buffer_target}). Story is complete.")
+ # Append IAMDONE if not already present
+ if 'IAMDONE' not in continuation.upper():
+ continuation = continuation.rstrip() + '\n\nIAMDONE'
+ is_complete = True
+ # Also check if we're at or very close to target
+ elif new_word_count >= target_total_words and (new_word_count - target_total_words) < 100:
+ logger.info(f"[StoryWriter] Word count ({new_word_count}) is at or very close to target ({target_total_words}). Story is complete.")
+ if 'IAMDONE' not in continuation.upper():
+ continuation = continuation.rstrip() + '\n\nIAMDONE'
+ is_complete = True
+
+ return StoryContinueResponse(
+ continuation=continuation,
+ is_complete=is_complete,
+ success=True
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to continue story: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Full Story Generation Endpoints (Async)
+# ---------------------------
+
+@router.post("/generate-full", response_model=Dict[str, Any])
+async def generate_full_story(
+ request: StoryGenerationRequest,
+ background_tasks: BackgroundTasks,
+ current_user: Dict[str, Any] = Depends(get_current_user),
+ max_iterations: int = 10
+) -> Dict[str, Any]:
+ """Generate a complete story asynchronously."""
+ try:
+ 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")
+
+ # Check cache first
+ cache_key = cache_manager.get_cache_key(request.dict())
+ cached_result = cache_manager.get_cached_result(cache_key)
+ if cached_result:
+ logger.info(f"[StoryWriter] Returning cached result for user {user_id}")
+ task_id = task_manager.create_task("story_generation")
+ task_manager.update_task_status(
+ task_id,
+ "completed",
+ progress=100.0,
+ result=cached_result,
+ message="Returned cached result"
+ )
+ return {"task_id": task_id, "cached": True}
+
+ # Create task
+ task_id = task_manager.create_task("story_generation")
+
+ # Prepare request data
+ request_data = request.dict()
+ request_data["max_iterations"] = max_iterations
+
+ # Execute task in background
+ background_tasks.add_task(
+ task_manager.execute_story_generation_task,
+ task_id=task_id,
+ request_data=request_data,
+ user_id=user_id
+ )
+
+ logger.info(f"[StoryWriter] Created task {task_id} for full story generation (user {user_id})")
+
+ return {
+ "task_id": task_id,
+ "status": "pending",
+ "message": "Story generation started. Use /task/{task_id}/status to check progress."
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to start story generation: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Task Management Endpoints
+# ---------------------------
+
+@router.get("/task/{task_id}/status", response_model=TaskStatus)
+async def get_task_status(
+ task_id: str,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> TaskStatus:
+ """Get the status of a story generation task."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ task_status = task_manager.get_task_status(task_id)
+
+ if not task_status:
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
+
+ return TaskStatus(**task_status)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to get task status: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/task/{task_id}/result", response_model=StoryFullGenerationResponse)
+async def get_task_result(
+ task_id: str,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryFullGenerationResponse:
+ """Get the result of a completed story generation task."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ task_status = task_manager.get_task_status(task_id)
+
+ if not task_status:
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
+
+ if task_status["status"] != "completed":
+ raise HTTPException(
+ status_code=400,
+ detail=f"Task {task_id} is not completed. Status: {task_status['status']}"
+ )
+
+ result = task_status.get("result")
+ if not result:
+ raise HTTPException(status_code=404, detail=f"No result found for task {task_id}")
+
+ return StoryFullGenerationResponse(**result, success=True, task_id=task_id)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to get task result: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Image Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-images", response_model=StoryImageGenerationResponse)
+async def generate_scene_images(
+ request: StoryImageGenerationRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryImageGenerationResponse:
+ """Generate images for story scenes."""
+ try:
+ 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")
+
+ if not request.scenes or len(request.scenes) == 0:
+ raise HTTPException(status_code=400, detail="At least one scene is required")
+
+ logger.info(f"[StoryWriter] Generating images for {len(request.scenes)} scenes for user {user_id}")
+
+ # Import image generation service
+ from services.story_writer.image_generation_service import StoryImageGenerationService
+
+ image_service = StoryImageGenerationService()
+
+ # Convert StoryScene models to dicts
+ scenes_data = [scene.dict() if isinstance(scene, StoryScene) else scene for scene in request.scenes]
+
+ # Generate images for all scenes
+ image_results = image_service.generate_scene_images(
+ scenes=scenes_data,
+ user_id=user_id,
+ provider=request.provider,
+ width=request.width or 1024,
+ height=request.height or 1024,
+ model=request.model
+ )
+
+ # Convert results to StoryImageResult models
+ image_models = [
+ StoryImageResult(
+ scene_number=result.get("scene_number", 0),
+ scene_title=result.get("scene_title", "Untitled"),
+ image_filename=result.get("image_filename", ""),
+ image_url=result.get("image_url", ""),
+ width=result.get("width", 1024),
+ height=result.get("height", 1024),
+ provider=result.get("provider", "unknown"),
+ model=result.get("model"),
+ seed=result.get("seed"),
+ error=result.get("error")
+ )
+ for result in image_results
+ ]
+
+ return StoryImageGenerationResponse(
+ images=image_models,
+ success=True
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate images: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/images/{image_filename}")
+async def serve_scene_image(
+ image_filename: str,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+):
+ """Serve a generated story scene image."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ # Import image generation service to get output directory
+ from services.story_writer.image_generation_service import StoryImageGenerationService
+ from fastapi.responses import FileResponse
+
+ image_service = StoryImageGenerationService()
+ image_path = image_service.output_dir / image_filename
+
+ if not image_path.exists():
+ raise HTTPException(status_code=404, detail=f"Image not found: {image_filename}")
+
+ # Validate that the file is within the output directory (security check)
+ try:
+ image_path.resolve().relative_to(image_service.output_dir.resolve())
+ except ValueError:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ return FileResponse(
+ path=str(image_path),
+ media_type="image/png",
+ filename=image_filename
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to serve image: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Audio Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-audio", response_model=StoryAudioGenerationResponse)
+async def generate_scene_audio(
+ request: StoryAudioGenerationRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryAudioGenerationResponse:
+ """Generate audio narration for story scenes."""
+ try:
+ 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")
+
+ if not request.scenes or len(request.scenes) == 0:
+ raise HTTPException(status_code=400, detail="At least one scene is required")
+
+ logger.info(f"[StoryWriter] Generating audio for {len(request.scenes)} scenes for user {user_id}")
+
+ # Import audio generation service
+ from services.story_writer.audio_generation_service import StoryAudioGenerationService
+
+ audio_service = StoryAudioGenerationService()
+
+ # Convert StoryScene models to dicts
+ scenes_data = [scene.dict() if isinstance(scene, StoryScene) else scene for scene in request.scenes]
+
+ # Generate audio for all scenes
+ audio_results = audio_service.generate_scene_audio_list(
+ scenes=scenes_data,
+ user_id=user_id,
+ provider=request.provider or "gtts",
+ lang=request.lang or "en",
+ slow=request.slow or False,
+ rate=request.rate or 150
+ )
+
+ # Convert results to StoryAudioResult models
+ # Ensure all required fields are strings, not None
+ audio_models = []
+ for result in audio_results:
+ # Handle None values by converting to empty strings for required fields
+ audio_url = result.get("audio_url") or ""
+ audio_filename = result.get("audio_filename") or ""
+
+ audio_models.append(
+ StoryAudioResult(
+ scene_number=result.get("scene_number", 0),
+ scene_title=result.get("scene_title", "Untitled"),
+ audio_filename=audio_filename,
+ audio_url=audio_url,
+ provider=result.get("provider", "unknown"),
+ file_size=result.get("file_size", 0),
+ error=result.get("error")
+ )
+ )
+
+ return StoryAudioGenerationResponse(
+ audio_files=audio_models,
+ success=True
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate audio: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/audio/{audio_filename}")
+async def serve_scene_audio(
+ audio_filename: str,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+):
+ """Serve a generated story scene audio file."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ # Import audio generation service to get output directory
+ from services.story_writer.audio_generation_service import StoryAudioGenerationService
+ from fastapi.responses import FileResponse
+
+ audio_service = StoryAudioGenerationService()
+ audio_path = audio_service.output_dir / audio_filename
+
+ if not audio_path.exists():
+ raise HTTPException(status_code=404, detail=f"Audio not found: {audio_filename}")
+
+ # Validate that the file is within the output directory (security check)
+ try:
+ audio_path.resolve().relative_to(audio_service.output_dir.resolve())
+ except ValueError:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ return FileResponse(
+ path=str(audio_path),
+ media_type="audio/mpeg",
+ filename=audio_filename
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to serve audio: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Video Generation Endpoints
+# ---------------------------
+
+@router.post("/generate-video", response_model=StoryVideoGenerationResponse)
+async def generate_story_video(
+ request: StoryVideoGenerationRequest,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> StoryVideoGenerationResponse:
+ """Generate a video from story scenes, images, and audio."""
+ try:
+ 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")
+
+ if not request.scenes or len(request.scenes) == 0:
+ raise HTTPException(status_code=400, detail="At least one scene is required")
+
+ if len(request.scenes) != len(request.image_urls) or len(request.scenes) != len(request.audio_urls):
+ raise HTTPException(status_code=400, detail="Number of scenes, image URLs, and audio URLs must match")
+
+ logger.info(f"[StoryWriter] Generating video for {len(request.scenes)} scenes for user {user_id}")
+
+ # Import video generation service and image/audio services
+ from services.story_writer.video_generation_service import StoryVideoGenerationService
+ from services.story_writer.image_generation_service import StoryImageGenerationService
+ from services.story_writer.audio_generation_service import StoryAudioGenerationService
+ from pathlib import Path
+
+ video_service = StoryVideoGenerationService()
+ image_service = StoryImageGenerationService()
+ audio_service = StoryAudioGenerationService()
+
+ # Convert StoryScene models to dicts
+ scenes_data = [scene.dict() if isinstance(scene, StoryScene) else scene for scene in request.scenes]
+
+ # Extract image and audio filenames from URLs
+ image_paths = []
+ audio_paths = []
+ valid_scenes = []
+
+ for idx, (scene, image_url, audio_url) in enumerate(zip(scenes_data, request.image_urls, request.audio_urls)):
+ # Extract filename from URL (e.g., "/api/story/images/scene_1_image.png" -> "scene_1_image.png")
+ # Handle both full URLs and relative paths
+ image_filename = image_url.split('/')[-1] if '/' in image_url else image_url
+ audio_filename = audio_url.split('/')[-1] if '/' in audio_url else audio_url
+
+ # Remove query parameters if present
+ image_filename = image_filename.split('?')[0]
+ audio_filename = audio_filename.split('?')[0]
+
+ # Construct full paths
+ image_path = image_service.output_dir / image_filename
+ audio_path = audio_service.output_dir / audio_filename
+
+ if not image_path.exists():
+ logger.warning(f"[StoryWriter] Image not found: {image_path} (from URL: {image_url})")
+ continue
+ if not audio_path.exists():
+ logger.warning(f"[StoryWriter] Audio not found: {audio_path} (from URL: {audio_url})")
+ continue
+
+ image_paths.append(str(image_path))
+ audio_paths.append(str(audio_path))
+ valid_scenes.append(scene)
+
+ if len(image_paths) == 0 or len(audio_paths) == 0:
+ raise HTTPException(status_code=400, detail="No valid image or audio files were found")
+
+ if len(image_paths) != len(audio_paths):
+ raise HTTPException(status_code=400, detail="Number of valid images and audio files must match")
+
+ # Use only valid scenes that have both image and audio
+ scenes_data = valid_scenes
+
+ # Generate video
+ video_result = video_service.generate_story_video(
+ scenes=scenes_data,
+ image_paths=image_paths,
+ audio_paths=audio_paths,
+ user_id=user_id,
+ story_title=request.story_title or "Story",
+ fps=request.fps or 24,
+ transition_duration=request.transition_duration or 0.5
+ )
+
+ # Convert result to StoryVideoResult model
+ video_model = StoryVideoResult(
+ video_filename=video_result.get("video_filename", ""),
+ video_url=video_result.get("video_url", ""),
+ duration=video_result.get("duration", 0.0),
+ fps=video_result.get("fps", 24),
+ file_size=video_result.get("file_size", 0),
+ num_scenes=video_result.get("num_scenes", 0),
+ error=video_result.get("error")
+ )
+
+ return StoryVideoGenerationResponse(
+ video=video_model,
+ success=True
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to generate video: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/generate-complete-video", response_model=Dict[str, Any])
+async def generate_complete_story_video(
+ request: StoryGenerationRequest,
+ background_tasks: BackgroundTasks,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Generate a complete story video (outline → images → audio → video) asynchronously."""
+ try:
+ 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")
+
+ logger.info(f"[StoryWriter] Starting complete video generation for user {user_id}")
+
+ # Create task
+ task_id = task_manager.create_task("complete_video_generation")
+
+ # Start background task
+ background_tasks.add_task(
+ execute_complete_video_generation,
+ task_id=task_id,
+ request_data=request.dict(),
+ user_id=user_id
+ )
+
+ return {
+ "task_id": task_id,
+ "status": "pending",
+ "message": "Complete video generation started"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to start complete video generation: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+def execute_complete_video_generation(
+ task_id: str,
+ request_data: Dict[str, Any],
+ user_id: str
+):
+ """
+ Execute complete video generation workflow synchronously.
+
+ This function runs in a background task and performs blocking operations.
+ It's not async because it calls synchronous methods from the services.
+ """
+ from services.story_writer.story_service import StoryWriterService
+ from services.story_writer.image_generation_service import StoryImageGenerationService
+ from services.story_writer.audio_generation_service import StoryAudioGenerationService
+ from services.story_writer.video_generation_service import StoryVideoGenerationService
+
+ service = StoryWriterService()
+ image_service = StoryImageGenerationService()
+ audio_service = StoryAudioGenerationService()
+ video_service = StoryVideoGenerationService()
+
+ try:
+ task_manager.update_task_status(task_id, "processing", progress=5.0, message="Starting complete video generation...")
+
+ # Step 1: Generate premise
+ task_manager.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 structured outline
+ task_manager.update_task_status(task_id, "processing", progress=20.0, message="Generating structured outline with scenes...")
+ outline_scenes = 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,
+ use_structured_output=True
+ )
+
+ if not isinstance(outline_scenes, list):
+ raise RuntimeError("Failed to generate structured outline")
+
+ # Step 3: Generate images for all scenes
+ # Progress range: 30-50% (20% total for image generation)
+ task_manager.update_task_status(task_id, "processing", progress=30.0, message="Generating images for scenes...")
+
+ def image_progress_callback(sub_progress: float, message: str):
+ """Map sub-progress (0-100) to overall progress (30-50%)."""
+ overall_progress = 30.0 + (sub_progress * 0.2)
+ task_manager.update_task_status(task_id, "processing", progress=overall_progress, message=message)
+
+ # Get image generation settings from request (with defaults)
+ image_provider = request_data.get("image_provider")
+ image_width = request_data.get("image_width", 1024)
+ image_height = request_data.get("image_height", 1024)
+ image_model = request_data.get("image_model")
+
+ image_results = image_service.generate_scene_images(
+ scenes=outline_scenes,
+ user_id=user_id,
+ provider=image_provider,
+ width=image_width,
+ height=image_height,
+ model=image_model,
+ progress_callback=image_progress_callback
+ )
+
+ # Step 4: Generate audio for all scenes
+ # Progress range: 50-70% (20% total for audio generation)
+ task_manager.update_task_status(task_id, "processing", progress=50.0, message="Generating audio narration for scenes...")
+
+ def audio_progress_callback(sub_progress: float, message: str):
+ """Map sub-progress (0-100) to overall progress (50-70%)."""
+ overall_progress = 50.0 + (sub_progress * 0.2)
+ task_manager.update_task_status(task_id, "processing", progress=overall_progress, message=message)
+
+ # Get audio generation settings from request (with defaults)
+ audio_provider = request_data.get("audio_provider", "gtts")
+ audio_lang = request_data.get("audio_lang", "en")
+ audio_slow = request_data.get("audio_slow", False)
+ audio_rate = request_data.get("audio_rate", 150)
+
+ audio_results = audio_service.generate_scene_audio_list(
+ scenes=outline_scenes,
+ user_id=user_id,
+ provider=audio_provider,
+ lang=audio_lang,
+ slow=audio_slow,
+ rate=audio_rate,
+ progress_callback=audio_progress_callback
+ )
+
+ # Step 5: Prepare image and audio paths
+ task_manager.update_task_status(task_id, "processing", progress=70.0, message="Preparing video assets...")
+ image_paths = []
+ audio_paths = []
+ valid_scenes = []
+
+ for scene in outline_scenes:
+ scene_number = scene.get("scene_number", 0)
+ image_result = next((img for img in image_results if img.get("scene_number") == scene_number), None)
+ audio_result = next((aud for aud in audio_results if aud.get("scene_number") == scene_number), None)
+
+ if image_result and audio_result and not image_result.get("error") and not audio_result.get("error"):
+ image_path = image_result.get("image_path")
+ audio_path = audio_result.get("audio_path")
+
+ if image_path and audio_path:
+ image_paths.append(image_path)
+ audio_paths.append(audio_path)
+ valid_scenes.append(scene)
+
+ if len(image_paths) == 0 or len(audio_paths) == 0:
+ raise RuntimeError(f"No valid images or audio files were generated. Images: {len(image_paths)}, Audio: {len(audio_paths)}")
+
+ if len(image_paths) != len(audio_paths):
+ raise RuntimeError(f"Mismatch between image and audio counts. Images: {len(image_paths)}, Audio: {len(audio_paths)}")
+
+ # Step 6: Generate video
+ # Progress range: 75-95% (20% total for video generation)
+ task_manager.update_task_status(task_id, "processing", progress=75.0, message="Composing video from scenes...")
+
+ def video_progress_callback(sub_progress: float, message: str):
+ """Map sub-progress (0-100) to overall progress (75-95%)."""
+ overall_progress = 75.0 + (sub_progress * 0.2)
+ task_manager.update_task_status(task_id, "processing", progress=overall_progress, message=message)
+
+ # Get video generation settings from request (with defaults)
+ video_fps = request_data.get("video_fps", 24)
+ video_transition_duration = request_data.get("video_transition_duration", 0.5)
+ story_title = request_data.get("story_setting", "Story")[:50]
+
+ video_result = video_service.generate_story_video(
+ scenes=valid_scenes,
+ image_paths=image_paths,
+ audio_paths=audio_paths,
+ user_id=user_id,
+ story_title=story_title,
+ fps=video_fps,
+ transition_duration=video_transition_duration,
+ progress_callback=video_progress_callback
+ )
+
+ # Prepare result
+ result = {
+ "premise": premise,
+ "outline_scenes": outline_scenes,
+ "images": image_results,
+ "audio_files": audio_results,
+ "video": video_result,
+ "success": True
+ }
+
+ task_manager.update_task_status(
+ task_id,
+ "completed",
+ progress=100.0,
+ message="Complete video generation finished!",
+ result=result
+ )
+
+ logger.info(f"[StoryWriter] Complete video generation task {task_id} completed successfully")
+
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"[StoryWriter] Complete video generation task {task_id} failed: {error_msg}", exc_info=True)
+ task_manager.update_task_status(
+ task_id,
+ "failed",
+ error=error_msg,
+ message=f"Complete video generation failed: {error_msg}"
+ )
+
+
+@router.get("/videos/{video_filename}")
+async def serve_story_video(
+ video_filename: str,
+ current_user: Dict[str, Any] = Depends(get_current_user)
+):
+ """Serve a generated story video file."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ # Import video generation service to get output directory
+ from services.story_writer.video_generation_service import StoryVideoGenerationService
+ from fastapi.responses import FileResponse
+
+ video_service = StoryVideoGenerationService()
+ video_path = video_service.output_dir / video_filename
+
+ if not video_path.exists():
+ raise HTTPException(status_code=404, detail=f"Video not found: {video_filename}")
+
+ # Validate that the file is within the output directory (security check)
+ try:
+ video_path.resolve().relative_to(video_service.output_dir.resolve())
+ except ValueError:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ return FileResponse(
+ path=str(video_path),
+ media_type="video/mp4",
+ filename=video_filename
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to serve video: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ---------------------------
+# Cache Management Endpoints
+# ---------------------------
+
+@router.get("/cache/stats")
+async def get_cache_stats(
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Get cache statistics."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ stats = cache_manager.get_cache_stats()
+ return {"success": True, "stats": stats}
+
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to get cache stats: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/cache/clear")
+async def clear_cache(
+ current_user: Dict[str, Any] = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Clear the story generation cache."""
+ try:
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ result = cache_manager.clear_cache()
+ return {"success": True, **result}
+
+ except Exception as e:
+ logger.error(f"[StoryWriter] Failed to clear cache: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/backend/api/story_writer/task_manager.py b/backend/api/story_writer/task_manager.py
new file mode 100644
index 00000000..58b876c0
--- /dev/null
+++ b/backend/api/story_writer/task_manager.py
@@ -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()
diff --git a/backend/api/subscription_api.py b/backend/api/subscription_api.py
index 8da81210..cb02204f 100644
--- a/backend/api/subscription_api.py
+++ b/backend/api/subscription_api.py
@@ -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))
\ No newline at end of file
+ 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)}")
\ No newline at end of file
diff --git a/backend/api/wix_routes.py b/backend/api/wix_routes.py
index fb43aac2..2ceda9cc 100644
--- a/backend/api/wix_routes.py
+++ b/backend/api/wix_routes.py
@@ -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)
diff --git a/backend/app.py b/backend/app.py
index d028e1ae..b14fd7fc 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -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}")
diff --git a/backend/models/oauth_token_monitoring_models.py b/backend/models/oauth_token_monitoring_models.py
index 259e6d00..842e4af4 100644
--- a/backend/models/oauth_token_monitoring_models.py
+++ b/backend/models/oauth_token_monitoring_models.py
@@ -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
diff --git a/backend/models/platform_insights_monitoring_models.py b/backend/models/platform_insights_monitoring_models.py
index 1f29e77a..c2ee77da 100644
--- a/backend/models/platform_insights_monitoring_models.py
+++ b/backend/models/platform_insights_monitoring_models.py
@@ -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
diff --git a/backend/models/story_models.py b/backend/models/story_models.py
new file mode 100644
index 00000000..d1d78459
--- /dev/null
+++ b/backend/models/story_models.py
@@ -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")
diff --git a/backend/models/subscription_models.py b/backend/models/subscription_models.py
index a6c73945..0b1e12b9 100644
--- a/backend/models/subscription_models.py
+++ b/backend/models/subscription_models.py
@@ -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)
\ No newline at end of file
+ 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'},
+ )
\ No newline at end of file
diff --git a/backend/models/website_analysis_monitoring_models.py b/backend/models/website_analysis_monitoring_models.py
index d20a92ba..c71c1619 100644
--- a/backend/models/website_analysis_monitoring_models.py
+++ b/backend/models/website_analysis_monitoring_models.py
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 76557032..5734dd24 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/scripts/check_wix_config.py b/backend/scripts/check_wix_config.py
new file mode 100644
index 00000000..e700f472
--- /dev/null
+++ b/backend/scripts/check_wix_config.py
@@ -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)
+
diff --git a/backend/scripts/run_failure_tracking_migration.py b/backend/scripts/run_failure_tracking_migration.py
new file mode 100644
index 00000000..3f871072
--- /dev/null
+++ b/backend/scripts/run_failure_tracking_migration.py
@@ -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)
+
diff --git a/backend/services/blog_writer/content/introduction_generator.py b/backend/services/blog_writer/content/introduction_generator.py
new file mode 100644
index 00000000..14451d27
--- /dev/null
+++ b/backend/services/blog_writer/content/introduction_generator.py
@@ -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
+
diff --git a/backend/services/blog_writer/outline/prompt_builder.py b/backend/services/blog_writer/outline/prompt_builder.py
index b18b9972..1c52169b 100644
--- a/backend/services/blog_writer/outline/prompt_builder.py
+++ b/backend/services/blog_writer/outline/prompt_builder.py
@@ -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"]
}
diff --git a/backend/services/blog_writer/outline/seo_title_generator.py b/backend/services/blog_writer/outline/seo_title_generator.py
new file mode 100644
index 00000000..ef777a9c
--- /dev/null
+++ b/backend/services/blog_writer/outline/seo_title_generator.py
@@ -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
+
diff --git a/backend/services/blog_writer/research/research_service.py b/backend/services/blog_writer/research/research_service.py
index 42ccd373..f8d8f505 100644
--- a/backend/services/blog_writer/research/research_service.py
+++ b/backend/services/blog_writer/research/research_service.py
@@ -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 = []
diff --git a/backend/services/integrations/wix/auth.py b/backend/services/integrations/wix/auth.py
index 17c0c2d9..6cc63fe2 100644
--- a/backend/services/integrations/wix/auth.py
+++ b/backend/services/integrations/wix/auth.py
@@ -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'
}
diff --git a/backend/services/integrations/wix/auth_utils.py b/backend/services/integrations/wix/auth_utils.py
new file mode 100644
index 00000000..3ed48dda
--- /dev/null
+++ b/backend/services/integrations/wix/auth_utils.py
@@ -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
+
diff --git a/backend/services/integrations/wix/blog.py b/backend/services/integrations/wix/blog.py
index 6476e05a..edd41183 100644
--- a/backend/services/integrations/wix/blog.py
+++ b/backend/services/integrations/wix/blog.py
@@ -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()
diff --git a/backend/services/integrations/wix/blog_publisher.py b/backend/services/integrations/wix/blog_publisher.py
index 7da9f7e8..6eaecb59 100644
--- a/backend/services/integrations/wix/blog_publisher.py
+++ b/backend/services/integrations/wix/blog_publisher.py
@@ -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:
diff --git a/backend/services/integrations/wix/content.py b/backend/services/integrations/wix/content.py
index 0a31aec4..df3a5be2 100644
--- a/backend/services/integrations/wix/content.py
+++ b/backend/services/integrations/wix/content.py
@@ -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
}
diff --git a/backend/services/integrations/wix/logger.py b/backend/services/integrations/wix/logger.py
new file mode 100644
index 00000000..bd892080
--- /dev/null
+++ b/backend/services/integrations/wix/logger.py
@@ -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()
+
diff --git a/backend/services/integrations/wix/ricos_converter.py b/backend/services/integrations/wix/ricos_converter.py
index faba70c7..9cc93ce7 100644
--- a/backend/services/integrations/wix/ricos_converter.py
+++ b/backend/services/integrations/wix/ricos_converter.py
@@ -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
tag before the truncation point
+ last_p_close = html_content.rfind('', 0, truncate_at)
+ if last_p_close > 0:
+ html_content = html_content[:last_p_close + 4] # Include the tag
+ else:
+ # If no paragraph boundary found, just truncate
+ html_content = html_content[:truncate_at]
+
+ # Add an ellipsis paragraph to indicate truncation
+ html_content += '... (Content truncated due to length constraints)
'
+
+ 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 = {
diff --git a/backend/services/integrations/wix/seo.py b/backend/services/integrations/wix/seo.py
index febf48c4..899a72d9 100644
--- a/backend/services/integrations/wix/seo.py
+++ b/backend/services/integrations/wix/seo.py
@@ -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
diff --git a/backend/services/scheduler/core/failure_detection_service.py b/backend/services/scheduler/core/failure_detection_service.py
new file mode 100644
index 00000000..493b0820
--- /dev/null
+++ b/backend/services/scheduler/core/failure_detection_service.py
@@ -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 []
+
diff --git a/backend/services/scheduler/core/task_execution_handler.py b/backend/services/scheduler/core/task_execution_handler.py
index d5ccd2db..3c60a0d8 100644
--- a/backend/services/scheduler/core/task_execution_handler.py
+++ b/backend/services/scheduler/core/task_execution_handler.py
@@ -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)
diff --git a/backend/services/scheduler/executors/bing_insights_executor.py b/backend/services/scheduler/executors/bing_insights_executor.py
index bea18558..f7e87fa2 100644
--- a/backend/services/scheduler/executors/bing_insights_executor.py
+++ b/backend/services/scheduler/executors/bing_insights_executor.py
@@ -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()
diff --git a/backend/services/scheduler/executors/gsc_insights_executor.py b/backend/services/scheduler/executors/gsc_insights_executor.py
index 8d03cc55..3ae1e875 100644
--- a/backend/services/scheduler/executors/gsc_insights_executor.py
+++ b/backend/services/scheduler/executors/gsc_insights_executor.py
@@ -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()
diff --git a/backend/services/scheduler/executors/oauth_token_monitoring_executor.py b/backend/services/scheduler/executors/oauth_token_monitoring_executor.py
index ee91057a..e482d1b6 100644
--- a/backend/services/scheduler/executors/oauth_token_monitoring_executor.py
+++ b/backend/services/scheduler/executors/oauth_token_monitoring_executor.py
@@ -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
diff --git a/backend/services/scheduler/executors/website_analysis_executor.py b/backend/services/scheduler/executors/website_analysis_executor.py
index 7a140e54..aba1498b 100644
--- a/backend/services/scheduler/executors/website_analysis_executor.py
+++ b/backend/services/scheduler/executors/website_analysis_executor.py
@@ -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
diff --git a/backend/services/story_writer/README.md b/backend/services/story_writer/README.md
new file mode 100644
index 00000000..8f5c13e1
--- /dev/null
+++ b/backend/services/story_writer/README.md
@@ -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.
diff --git a/backend/services/story_writer/__init__.py b/backend/services/story_writer/__init__.py
new file mode 100644
index 00000000..b979e768
--- /dev/null
+++ b/backend/services/story_writer/__init__.py
@@ -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']
diff --git a/backend/services/story_writer/audio_generation_service.py b/backend/services/story_writer/audio_generation_service.py
new file mode 100644
index 00000000..e75ec296
--- /dev/null
+++ b/backend/services/story_writer/audio_generation_service.py
@@ -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
+
diff --git a/backend/services/story_writer/image_generation_service.py b/backend/services/story_writer/image_generation_service.py
new file mode 100644
index 00000000..f668fedc
--- /dev/null
+++ b/backend/services/story_writer/image_generation_service.py
@@ -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
+
diff --git a/backend/services/story_writer/service_components/__init__.py b/backend/services/story_writer/service_components/__init__.py
new file mode 100644
index 00000000..398be8e1
--- /dev/null
+++ b/backend/services/story_writer/service_components/__init__.py
@@ -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",
+]
+
diff --git a/backend/services/story_writer/service_components/base.py b/backend/services/story_writer/service_components/base.py
new file mode 100644
index 00000000..91b391af
--- /dev/null
+++ b/backend/services/story_writer/service_components/base.py
@@ -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)}")
+
diff --git a/backend/services/story_writer/service_components/outline.py b/backend/services/story_writer/service_components/outline.py
new file mode 100644
index 00000000..ced0c213
--- /dev/null
+++ b/backend/services/story_writer/service_components/outline.py
@@ -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
+
diff --git a/backend/services/story_writer/service_components/setup.py b/backend/services/story_writer/service_components/setup.py
new file mode 100644
index 00000000..0be30740
--- /dev/null
+++ b/backend/services/story_writer/service_components/setup.py
@@ -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
+
diff --git a/backend/services/story_writer/service_components/story_content.py b/backend/services/story_writer/service_components/story_content.py
new file mode 100644
index 00000000..d2be9b3a
--- /dev/null
+++ b/backend/services/story_writer/service_components/story_content.py
@@ -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
+ )
+
diff --git a/backend/services/story_writer/story_service.py b/backend/services/story_writer/story_service.py
new file mode 100644
index 00000000..c74bc7b8
--- /dev/null
+++ b/backend/services/story_writer/story_service.py
@@ -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__ = ()
diff --git a/backend/services/story_writer/video_generation_service.py b/backend/services/story_writer/video_generation_service.py
new file mode 100644
index 00000000..87c7883c
--- /dev/null
+++ b/backend/services/story_writer/video_generation_service.py
@@ -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
+
diff --git a/backend/services/subscription/log_wrapping_service.py b/backend/services/subscription/log_wrapping_service.py
new file mode 100644
index 00000000..7dfebcad
--- /dev/null
+++ b/backend/services/subscription/log_wrapping_service.py
@@ -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
+
diff --git a/backend/services/subscription/pricing_service.py b/backend/services/subscription/pricing_service.py
index a72912c5..bce611ad 100644
--- a/backend/services/subscription/pricing_service.py
+++ b/backend/services/subscription/pricing_service.py
@@ -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
diff --git a/backend/services/subscription/usage_tracking_service.py b/backend/services/subscription/usage_tracking_service.py
index 94efd731..98d54e56 100644
--- a/backend/services/subscription/usage_tracking_service.py
+++ b/backend/services/subscription/usage_tracking_service.py
@@ -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,
diff --git a/backend/services/wix_service.py b/backend/services/wix_service.py
index 29761455..855adb1e 100644
--- a/backend/services/wix_service.py
+++ b/backend/services/wix_service.py
@@ -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,
diff --git a/backend/story_images/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_6818cab1.png b/backend/story_images/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_6818cab1.png
new file mode 100644
index 0000000000000000000000000000000000000000..491b73216112a04e4695776a3c9534476162d851
GIT binary patch
literal 1209263
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