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

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

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -0,0 +1,9 @@
"""
Story Writer API
API endpoints for story generation functionality.
"""
from .router import router
__all__ = ['router']

View File

@@ -0,0 +1,70 @@
"""
Cache Management System for Story Writer API
Handles story generation cache operations.
"""
from typing import Any, Dict, Optional
from loguru import logger
class CacheManager:
"""Manages cache operations for story generation data."""
def __init__(self):
"""Initialize the cache manager."""
self.cache: Dict[str, Dict[str, Any]] = {}
logger.info("[StoryWriter] CacheManager initialized")
def get_cache_key(self, request_data: Dict[str, Any]) -> str:
"""Generate a cache key from request data."""
import hashlib
import json
# Create a normalized version of the request for caching
cache_data = {
"persona": request_data.get("persona", ""),
"story_setting": request_data.get("story_setting", ""),
"character_input": request_data.get("character_input", ""),
"plot_elements": request_data.get("plot_elements", ""),
"writing_style": request_data.get("writing_style", ""),
"story_tone": request_data.get("story_tone", ""),
"narrative_pov": request_data.get("narrative_pov", ""),
"audience_age_group": request_data.get("audience_age_group", ""),
"content_rating": request_data.get("content_rating", ""),
"ending_preference": request_data.get("ending_preference", ""),
}
cache_str = json.dumps(cache_data, sort_keys=True)
return hashlib.md5(cache_str.encode()).hexdigest()
def get_cached_result(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get a cached result if available."""
if cache_key in self.cache:
logger.debug(f"[StoryWriter] Cache hit for key: {cache_key}")
return self.cache[cache_key]
logger.debug(f"[StoryWriter] Cache miss for key: {cache_key}")
return None
def cache_result(self, cache_key: str, result: Dict[str, Any]):
"""Cache a result."""
self.cache[cache_key] = result
logger.debug(f"[StoryWriter] Cached result for key: {cache_key}")
def clear_cache(self):
"""Clear all cached results."""
count = len(self.cache)
self.cache.clear()
logger.info(f"[StoryWriter] Cleared {count} cached entries")
return {"status": "success", "message": f"Cleared {count} cached entries"}
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics."""
return {
"total_entries": len(self.cache),
"cache_keys": list(self.cache.keys())
}
# Global cache manager instance
cache_manager = CacheManager()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
"""
Task Management System for Story Writer API
Handles background task execution, status tracking, and progress updates
for story generation operations.
"""
import asyncio
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from loguru import logger
class TaskManager:
"""Manages background tasks for story generation."""
def __init__(self):
"""Initialize the task manager."""
self.task_storage: Dict[str, Dict[str, Any]] = {}
logger.info("[StoryWriter] TaskManager initialized")
def cleanup_old_tasks(self):
"""Remove tasks older than 1 hour to prevent memory leaks."""
current_time = datetime.now()
tasks_to_remove = []
for task_id, task_data in self.task_storage.items():
created_at = task_data.get("created_at")
if created_at and (current_time - created_at).total_seconds() > 3600: # 1 hour
tasks_to_remove.append(task_id)
for task_id in tasks_to_remove:
del self.task_storage[task_id]
logger.debug(f"[StoryWriter] Cleaned up old task: {task_id}")
def create_task(self, task_type: str = "story_generation") -> str:
"""Create a new task and return its ID."""
task_id = str(uuid.uuid4())
self.task_storage[task_id] = {
"status": "pending",
"created_at": datetime.now(),
"result": None,
"error": None,
"progress_messages": [],
"task_type": task_type,
"progress": 0.0
}
logger.info(f"[StoryWriter] Created task: {task_id} (type: {task_type})")
return task_id
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""Get the status of a task."""
self.cleanup_old_tasks()
if task_id not in self.task_storage:
logger.warning(f"[StoryWriter] Task not found: {task_id}")
return None
task = self.task_storage[task_id]
response = {
"task_id": task_id,
"status": task["status"],
"progress": task.get("progress", 0.0),
"message": task.get("progress_messages", [])[-1] if task.get("progress_messages") else None,
"created_at": task["created_at"].isoformat() if task.get("created_at") else None,
"updated_at": task.get("updated_at", task.get("created_at")).isoformat() if task.get("updated_at") or task.get("created_at") else None,
}
if task["status"] == "completed" and task.get("result"):
response["result"] = task["result"]
if task["status"] == "failed" and task.get("error"):
response["error"] = task["error"]
return response
def update_task_status(
self,
task_id: str,
status: str,
progress: Optional[float] = None,
message: Optional[str] = None,
result: Optional[Dict[str, Any]] = None,
error: Optional[str] = None
):
"""Update the status of a task."""
if task_id not in self.task_storage:
logger.warning(f"[StoryWriter] Cannot update non-existent task: {task_id}")
return
task = self.task_storage[task_id]
task["status"] = status
task["updated_at"] = datetime.now()
if progress is not None:
task["progress"] = progress
if message:
if "progress_messages" not in task:
task["progress_messages"] = []
task["progress_messages"].append(message)
logger.info(f"[StoryWriter] Task {task_id}: {message} (progress: {progress}%)")
if result is not None:
task["result"] = result
if error is not None:
task["error"] = error
logger.error(f"[StoryWriter] Task {task_id} error: {error}")
async def execute_story_generation_task(
self,
task_id: str,
request_data: Dict[str, Any],
user_id: str
):
"""Execute story generation task asynchronously."""
from services.story_writer.story_service import StoryWriterService
service = StoryWriterService()
try:
self.update_task_status(task_id, "processing", progress=0.0, message="Starting story generation...")
# Step 1: Generate premise
self.update_task_status(task_id, "processing", progress=10.0, message="Generating story premise...")
premise = service.generate_premise(
persona=request_data["persona"],
story_setting=request_data["story_setting"],
character_input=request_data["character_input"],
plot_elements=request_data["plot_elements"],
writing_style=request_data["writing_style"],
story_tone=request_data["story_tone"],
narrative_pov=request_data["narrative_pov"],
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
)
# Step 2: Generate outline
self.update_task_status(task_id, "processing", progress=30.0, message="Generating story outline...")
outline = service.generate_outline(
premise=premise,
persona=request_data["persona"],
story_setting=request_data["story_setting"],
character_input=request_data["character_input"],
plot_elements=request_data["plot_elements"],
writing_style=request_data["writing_style"],
story_tone=request_data["story_tone"],
narrative_pov=request_data["narrative_pov"],
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
)
# Step 3: Generate story start
self.update_task_status(task_id, "processing", progress=50.0, message="Writing story beginning...")
story_start = service.generate_story_start(
premise=premise,
outline=outline,
persona=request_data["persona"],
story_setting=request_data["story_setting"],
character_input=request_data["character_input"],
plot_elements=request_data["plot_elements"],
writing_style=request_data["writing_style"],
story_tone=request_data["story_tone"],
narrative_pov=request_data["narrative_pov"],
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
)
# Step 4: Continue story
self.update_task_status(task_id, "processing", progress=70.0, message="Continuing story generation...")
story_text = story_start
max_iterations = request_data.get("max_iterations", 10)
iteration = 0
while 'IAMDONE' not in story_text and iteration < max_iterations:
iteration += 1
progress = 70.0 + (iteration / max_iterations) * 25.0
self.update_task_status(
task_id,
"processing",
progress=min(progress, 95.0),
message=f"Writing continuation {iteration}/{max_iterations}..."
)
continuation = service.continue_story(
premise=premise,
outline=outline,
story_text=story_text,
persona=request_data["persona"],
story_setting=request_data["story_setting"],
character_input=request_data["character_input"],
plot_elements=request_data["plot_elements"],
writing_style=request_data["writing_style"],
story_tone=request_data["story_tone"],
narrative_pov=request_data["narrative_pov"],
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
)
if continuation:
story_text += '\n\n' + continuation
else:
logger.warning(f"[StoryWriter] Empty continuation at iteration {iteration}")
break
# Clean up and finalize
final_story = story_text.replace('IAMDONE', '').strip()
result = {
"premise": premise,
"outline": outline,
"story": final_story,
"is_complete": 'IAMDONE' in story_text or iteration >= max_iterations,
"iterations": iteration
}
self.update_task_status(
task_id,
"completed",
progress=100.0,
message="Story generation completed!",
result=result
)
logger.info(f"[StoryWriter] Task {task_id} completed successfully")
except Exception as e:
error_msg = str(e)
logger.error(f"[StoryWriter] Task {task_id} failed: {error_msg}")
self.update_task_status(
task_id,
"failed",
error=error_msg,
message=f"Story generation failed: {error_msg}"
)
# Global task manager instance
task_manager = TaskManager()

View File

@@ -5,6 +5,7 @@ Provides endpoints for subscription management and usage monitoring.
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from loguru import logger
@@ -12,12 +13,14 @@ from functools import lru_cache
from services.database import get_db
from services.subscription import UsageTrackingService, PricingService
from services.subscription.log_wrapping_service import LogWrappingService
from services.subscription.schema_utils import ensure_subscription_plan_columns
import sqlite3
from middleware.auth_middleware import get_current_user
from models.subscription_models import (
APIProvider, SubscriptionPlan, UserSubscription, UsageSummary,
APIProviderPricing, UsageAlert, SubscriptionTier, BillingCycle, UsageStatus
APIProviderPricing, UsageAlert, SubscriptionTier, BillingCycle, UsageStatus,
APIUsageLog, SubscriptionRenewalHistory
)
router = APIRouter(prefix="/api/subscription", tags=["subscription"])
@@ -525,8 +528,67 @@ async def subscribe_to_plan(
).first()
now = datetime.utcnow()
# Track renewal history - capture BEFORE updating subscription
previous_period_start = None
previous_period_end = None
previous_plan_name = None
previous_plan_tier = None
renewal_type = "new"
renewal_count = 0
# Get usage snapshot BEFORE renewal (capture current state)
usage_before_snapshot = None
current_period = datetime.utcnow().strftime("%Y-%m")
usage_before = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
if usage_before:
usage_before_snapshot = {
"total_calls": usage_before.total_calls or 0,
"total_tokens": usage_before.total_tokens or 0,
"total_cost": float(usage_before.total_cost) if usage_before.total_cost else 0.0,
"gemini_calls": usage_before.gemini_calls or 0,
"mistral_calls": usage_before.mistral_calls or 0,
"usage_status": usage_before.usage_status.value if hasattr(usage_before.usage_status, 'value') else str(usage_before.usage_status)
}
if existing_subscription:
# This is a renewal/update - capture previous subscription state BEFORE updating
previous_period_start = existing_subscription.current_period_start
previous_period_end = existing_subscription.current_period_end
previous_plan = existing_subscription.plan
previous_plan_name = previous_plan.name if previous_plan else None
previous_plan_tier = previous_plan.tier.value if previous_plan else None
# Determine renewal type
if previous_plan and previous_plan.id == plan_id:
# Same plan - this is a renewal
renewal_type = "renewal"
elif previous_plan:
# Different plan - check if upgrade or downgrade
tier_order = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
previous_tier_order = tier_order.get(previous_plan_tier or "free", 0)
new_tier_order = tier_order.get(plan.tier.value, 0)
if new_tier_order > previous_tier_order:
renewal_type = "upgrade"
elif new_tier_order < previous_tier_order:
renewal_type = "downgrade"
else:
renewal_type = "renewal" # Same tier, different plan name
# Get renewal count (how many times this user has renewed)
last_renewal = db.query(SubscriptionRenewalHistory).filter(
SubscriptionRenewalHistory.user_id == user_id
).order_by(SubscriptionRenewalHistory.created_at.desc()).first()
if last_renewal:
renewal_count = last_renewal.renewal_count + 1
else:
renewal_count = 1 # First renewal
# Update existing subscription
existing_subscription.plan_id = plan_id
existing_subscription.billing_cycle = BillingCycle(billing_cycle)
@@ -552,7 +614,30 @@ async def subscribe_to_plan(
auto_renew=True
)
db.add(subscription)
db.commit()
# Create renewal history record AFTER subscription update (so we have the new period_end)
renewal_history = SubscriptionRenewalHistory(
user_id=user_id,
plan_id=plan_id,
plan_name=plan.name,
plan_tier=plan.tier.value,
previous_period_start=previous_period_start,
previous_period_end=previous_period_end,
new_period_start=now,
new_period_end=subscription.current_period_end,
billing_cycle=BillingCycle(billing_cycle),
renewal_type=renewal_type,
renewal_count=renewal_count,
previous_plan_name=previous_plan_name,
previous_plan_tier=previous_plan_tier,
usage_before_renewal=usage_before_snapshot, # Usage snapshot captured BEFORE renewal
payment_amount=plan.price_yearly if billing_cycle == 'yearly' else plan.price_monthly,
payment_status="paid", # Assume paid for now (can be updated if payment processing is added)
payment_date=now
)
db.add(renewal_history)
db.commit()
# Get current usage BEFORE reset for logging
@@ -883,4 +968,222 @@ async def get_dashboard_data(
except Exception as e:
logger.error(f"Error getting dashboard data: {e}")
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/renewal-history/{user_id}")
async def get_renewal_history(
user_id: str,
limit: int = Query(50, ge=1, le=100, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
Get subscription renewal history for a user.
Returns:
- List of renewal history records
- Total count for pagination
"""
try:
# Verify user can only access their own data
if current_user.get('id') != user_id:
raise HTTPException(status_code=403, detail="Access denied")
# Get total count
total_count = db.query(SubscriptionRenewalHistory).filter(
SubscriptionRenewalHistory.user_id == user_id
).count()
# Get paginated results, ordered by created_at descending (most recent first)
renewals = db.query(SubscriptionRenewalHistory).filter(
SubscriptionRenewalHistory.user_id == user_id
).order_by(SubscriptionRenewalHistory.created_at.desc()).offset(offset).limit(limit).all()
# Format renewal history for response
renewal_history = []
for renewal in renewals:
renewal_history.append({
'id': renewal.id,
'plan_name': renewal.plan_name,
'plan_tier': renewal.plan_tier,
'previous_period_start': renewal.previous_period_start.isoformat() if renewal.previous_period_start else None,
'previous_period_end': renewal.previous_period_end.isoformat() if renewal.previous_period_end else None,
'new_period_start': renewal.new_period_start.isoformat() if renewal.new_period_start else None,
'new_period_end': renewal.new_period_end.isoformat() if renewal.new_period_end else None,
'billing_cycle': renewal.billing_cycle.value if renewal.billing_cycle else None,
'renewal_type': renewal.renewal_type,
'renewal_count': renewal.renewal_count,
'previous_plan_name': renewal.previous_plan_name,
'previous_plan_tier': renewal.previous_plan_tier,
'usage_before_renewal': renewal.usage_before_renewal,
'payment_amount': float(renewal.payment_amount) if renewal.payment_amount else 0.0,
'payment_status': renewal.payment_status,
'payment_date': renewal.payment_date.isoformat() if renewal.payment_date else None,
'created_at': renewal.created_at.isoformat() if renewal.created_at else None
})
return {
"success": True,
"data": {
"renewals": renewal_history,
"total_count": total_count,
"limit": limit,
"offset": offset,
"has_more": (offset + limit) < total_count
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting renewal history: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/usage-logs")
async def get_usage_logs(
limit: int = Query(50, ge=1, le=5000, description="Number of logs to return"),
offset: int = Query(0, ge=0, description="Pagination offset"),
provider: Optional[str] = Query(None, description="Filter by provider"),
status_code: Optional[int] = Query(None, description="Filter by HTTP status code"),
billing_period: Optional[str] = Query(None, description="Filter by billing period (YYYY-MM)"),
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
Get API usage logs for the current user.
Query Params:
- limit: Number of logs to return (1-500, default: 50)
- offset: Pagination offset (default: 0)
- provider: Filter by provider (e.g., "gemini", "openai", "huggingface")
- status_code: Filter by HTTP status code (e.g., 200 for success, 400+ for errors)
- billing_period: Filter by billing period (YYYY-MM format)
Returns:
- List of usage logs with API call details
- Total count for pagination
"""
try:
# Get user_id from current_user
user_id = str(current_user.get('id', '')) if current_user else None
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")
# Build query
query = db.query(APIUsageLog).filter(
APIUsageLog.user_id == user_id
)
# Apply filters
if provider:
provider_lower = provider.lower()
# Handle special case: huggingface maps to MISTRAL enum in database
if provider_lower == "huggingface":
provider_enum = APIProvider.MISTRAL
else:
try:
provider_enum = APIProvider(provider_lower)
except ValueError:
# Invalid provider, return empty results
return {
"logs": [],
"total_count": 0,
"limit": limit,
"offset": offset,
"has_more": False
}
query = query.filter(APIUsageLog.provider == provider_enum)
if status_code is not None:
query = query.filter(APIUsageLog.status_code == status_code)
if billing_period:
query = query.filter(APIUsageLog.billing_period == billing_period)
# Check and wrap logs if necessary (before getting count)
wrapping_service = LogWrappingService(db)
wrap_result = wrapping_service.check_and_wrap_logs(user_id)
if wrap_result.get('wrapped'):
logger.info(f"[UsageLogs] Log wrapping completed for user {user_id}: {wrap_result.get('message')}")
# Rebuild query after wrapping (in case filters changed)
query = db.query(APIUsageLog).filter(
APIUsageLog.user_id == user_id
)
# Reapply filters
if provider:
provider_lower = provider.lower()
if provider_lower == "huggingface":
provider_enum = APIProvider.MISTRAL
else:
try:
provider_enum = APIProvider(provider_lower)
except ValueError:
return {
"logs": [],
"total_count": 0,
"limit": limit,
"offset": offset,
"has_more": False
}
query = query.filter(APIUsageLog.provider == provider_enum)
if status_code is not None:
query = query.filter(APIUsageLog.status_code == status_code)
if billing_period:
query = query.filter(APIUsageLog.billing_period == billing_period)
# Get total count
total_count = query.count()
# Get paginated results, ordered by timestamp descending (most recent first)
logs = query.order_by(desc(APIUsageLog.timestamp)).offset(offset).limit(limit).all()
# Format logs for response
formatted_logs = []
for log in logs:
# Determine status based on status_code
status = 'success' if 200 <= log.status_code < 300 else 'failed'
# Handle provider display name - ALL MISTRAL enum logs are actually HuggingFace
# (HuggingFace always maps to MISTRAL enum in the database)
provider_display = log.provider.value if log.provider else None
if provider_display == "mistral":
# All MISTRAL provider logs are HuggingFace calls
provider_display = "huggingface"
formatted_logs.append({
'id': log.id,
'timestamp': log.timestamp.isoformat() if log.timestamp else None,
'provider': provider_display,
'model_used': log.model_used,
'endpoint': log.endpoint,
'method': log.method,
'tokens_input': log.tokens_input or 0,
'tokens_output': log.tokens_output or 0,
'tokens_total': log.tokens_total or 0,
'cost_input': float(log.cost_input) if log.cost_input else 0.0,
'cost_output': float(log.cost_output) if log.cost_output else 0.0,
'cost_total': float(log.cost_total) if log.cost_total else 0.0,
'response_time': float(log.response_time) if log.response_time else 0.0,
'status_code': log.status_code,
'status': status,
'error_message': log.error_message,
'billing_period': log.billing_period,
'retry_count': log.retry_count or 0,
'is_aggregated': log.endpoint == "[AGGREGATED]" # Flag to indicate aggregated log
})
return {
"logs": formatted_logs,
"total_count": total_count,
"limit": limit,
"offset": offset,
"has_more": (offset + limit) < total_count
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting usage logs: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get usage logs: {str(e)}")

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,262 @@
"""
Story Writer Models
Pydantic models for story generation API requests and responses.
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Union
class StoryGenerationRequest(BaseModel):
"""Request model for story generation."""
persona: str = Field(..., description="The persona statement for the author")
story_setting: str = Field(..., description="The setting of the story")
character_input: str = Field(..., description="The characters in the story")
plot_elements: str = Field(..., description="The plot elements of the story")
writing_style: str = Field(..., description="The writing style (e.g., Formal, Casual, Poetic, Humorous)")
story_tone: str = Field(..., description="The tone of the story (e.g., Dark, Uplifting, Suspenseful, Whimsical)")
narrative_pov: str = Field(..., description="The narrative point of view (e.g., First Person, Third Person Limited, Third Person Omniscient)")
audience_age_group: str = Field(..., description="The target audience age group (e.g., Children, Young Adults, Adults)")
content_rating: str = Field(..., description="The content rating (e.g., G, PG, PG-13, R)")
ending_preference: str = Field(..., description="The preferred ending (e.g., Happy, Tragic, Cliffhanger, Twist)")
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
enable_explainer: bool = Field(default=True, description="Enable explainer features")
enable_illustration: bool = Field(default=True, description="Enable illustration features")
enable_video_narration: bool = Field(default=True, description="Enable story video and narration features")
# Image generation settings
image_provider: Optional[str] = Field(default=None, description="Image generation provider (gemini, huggingface, stability)")
image_width: int = Field(default=1024, description="Image width in pixels")
image_height: int = Field(default=1024, description="Image height in pixels")
image_model: Optional[str] = Field(default=None, description="Image generation model")
# Video generation settings
video_fps: int = Field(default=24, description="Frames per second for video")
video_transition_duration: float = Field(default=0.5, description="Duration of transitions between scenes in seconds")
# Audio generation settings
audio_provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
audio_lang: str = Field(default="en", description="Language code for TTS")
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
class StorySetupGenerationRequest(BaseModel):
"""Request model for AI story setup generation."""
story_idea: str = Field(..., description="Basic story idea or information from the user")
class StorySetupOption(BaseModel):
"""A single story setup option."""
persona: str = Field(..., description="The persona statement for the author")
story_setting: str = Field(..., description="The setting of the story")
character_input: str = Field(..., description="The characters in the story")
plot_elements: str = Field(..., description="The plot elements of the story")
writing_style: str = Field(..., description="The writing style")
story_tone: str = Field(..., description="The tone of the story")
narrative_pov: str = Field(..., description="The narrative point of view")
audience_age_group: str = Field(..., description="The target audience age group")
content_rating: str = Field(..., description="The content rating")
ending_preference: str = Field(..., description="The preferred ending")
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
premise: str = Field(..., description="The story premise (1-2 sentences)")
reasoning: str = Field(..., description="Brief reasoning for this setup option")
# Image generation settings
image_provider: Optional[str] = Field(default=None, description="Image generation provider (gemini, huggingface, stability)")
image_width: int = Field(default=1024, description="Image width in pixels")
image_height: int = Field(default=1024, description="Image height in pixels")
image_model: Optional[str] = Field(default=None, description="Image generation model")
# Video generation settings
video_fps: int = Field(default=24, description="Frames per second for video")
video_transition_duration: float = Field(default=0.5, description="Duration of transitions between scenes in seconds")
# Audio generation settings
audio_provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
audio_lang: str = Field(default="en", description="Language code for TTS")
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
class StorySetupGenerationResponse(BaseModel):
"""Response model for story setup generation."""
options: List[StorySetupOption] = Field(..., description="Three story setup options")
success: bool = Field(default=True, description="Whether the generation was successful")
class StoryScene(BaseModel):
"""Model for a story scene."""
scene_number: int = Field(..., description="Scene number")
title: str = Field(..., description="Scene title")
description: str = Field(..., description="Scene description")
image_prompt: str = Field(..., description="Image prompt for scene visualization")
audio_narration: str = Field(..., description="Audio narration text for the scene")
character_descriptions: List[str] = Field(default_factory=list, description="Character descriptions in the scene")
key_events: List[str] = Field(default_factory=list, description="Key events in the scene")
class StoryStartRequest(StoryGenerationRequest):
"""Request model for story start generation."""
premise: str = Field(..., description="The story premise")
outline: Union[str, List[StoryScene], List[Dict[str, Any]]] = Field(..., description="The story outline (text or structured scenes)")
class StoryPremiseResponse(BaseModel):
"""Response model for premise generation."""
premise: str = Field(..., description="Generated story premise")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
class StoryOutlineResponse(BaseModel):
"""Response model for outline generation."""
outline: Union[str, List[StoryScene]] = Field(..., description="Generated story outline (text or structured scenes)")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
is_structured: bool = Field(default=False, description="Whether the outline is structured (scenes) or plain text")
class StoryContentResponse(BaseModel):
"""Response model for story content generation."""
story: str = Field(..., description="Generated story content")
premise: Optional[str] = Field(None, description="Story premise")
outline: Optional[str] = Field(None, description="Story outline")
is_complete: bool = Field(default=False, description="Whether the story is complete")
iterations: int = Field(default=0, description="Number of continuation iterations")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
class StoryFullGenerationResponse(BaseModel):
"""Response model for full story generation."""
premise: str = Field(..., description="Generated story premise")
outline: str = Field(..., description="Generated story outline")
story: str = Field(..., description="Generated complete story")
is_complete: bool = Field(default=False, description="Whether the story is complete")
iterations: int = Field(default=0, description="Number of continuation iterations")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
class StoryContinueRequest(BaseModel):
"""Request model for continuing story generation."""
premise: str = Field(..., description="The story premise")
outline: Union[str, List[StoryScene], List[Dict[str, Any]]] = Field(..., description="The story outline (text or structured scenes)")
story_text: str = Field(..., description="Current story text to continue from")
persona: str = Field(..., description="The persona statement for the author")
story_setting: str = Field(..., description="The setting of the story")
character_input: str = Field(..., description="The characters in the story")
plot_elements: str = Field(..., description="The plot elements of the story")
writing_style: str = Field(..., description="The writing style")
story_tone: str = Field(..., description="The tone of the story")
narrative_pov: str = Field(..., description="The narrative point of view")
audience_age_group: str = Field(..., description="The target audience age group")
content_rating: str = Field(..., description="The content rating")
ending_preference: str = Field(..., description="The preferred ending")
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
class StoryContinueResponse(BaseModel):
"""Response model for story continuation."""
continuation: str = Field(..., description="Generated story continuation")
is_complete: bool = Field(default=False, description="Whether the story is complete (contains IAMDONE)")
success: bool = Field(default=True, description="Whether the generation was successful")
class TaskStatus(BaseModel):
"""Task status model."""
task_id: str = Field(..., description="Task ID")
status: str = Field(..., description="Task status (pending, processing, completed, failed)")
progress: Optional[float] = Field(None, description="Progress percentage (0-100)")
message: Optional[str] = Field(None, description="Progress message")
result: Optional[Dict[str, Any]] = Field(None, description="Task result when completed")
error: Optional[str] = Field(None, description="Error message if failed")
created_at: Optional[str] = Field(None, description="Task creation timestamp")
updated_at: Optional[str] = Field(None, description="Task last update timestamp")
class StoryImageGenerationRequest(BaseModel):
"""Request model for image generation."""
scenes: List[StoryScene] = Field(..., description="List of scenes to generate images for")
provider: Optional[str] = Field(None, description="Image generation provider (gemini, huggingface, stability)")
width: Optional[int] = Field(default=1024, description="Image width")
height: Optional[int] = Field(default=1024, description="Image height")
model: Optional[str] = Field(None, description="Image generation model")
class StoryImageResult(BaseModel):
"""Model for a generated image result."""
scene_number: int = Field(..., description="Scene number")
scene_title: str = Field(..., description="Scene title")
image_filename: str = Field(..., description="Image filename")
image_url: str = Field(..., description="Image URL")
width: int = Field(..., description="Image width")
height: int = Field(..., description="Image height")
provider: str = Field(..., description="Image generation provider")
model: Optional[str] = Field(None, description="Image generation model")
seed: Optional[int] = Field(None, description="Image generation seed")
error: Optional[str] = Field(None, description="Error message if generation failed")
class StoryImageGenerationResponse(BaseModel):
"""Response model for image generation."""
images: List[StoryImageResult] = Field(..., description="List of generated images")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
class StoryAudioGenerationRequest(BaseModel):
"""Request model for audio generation."""
scenes: List[StoryScene] = Field(..., description="List of scenes to generate audio for")
provider: Optional[str] = Field(default="gtts", description="TTS provider (gtts, pyttsx3)")
lang: Optional[str] = Field(default="en", description="Language code for TTS")
slow: Optional[bool] = Field(default=False, description="Whether to speak slowly (gTTS only)")
rate: Optional[int] = Field(default=150, description="Speech rate (pyttsx3 only)")
class StoryAudioResult(BaseModel):
"""Model for a generated audio result."""
scene_number: int = Field(..., description="Scene number")
scene_title: str = Field(..., description="Scene title")
audio_filename: str = Field(..., description="Audio filename")
audio_url: str = Field(..., description="Audio URL")
provider: str = Field(..., description="TTS provider")
file_size: int = Field(..., description="Audio file size in bytes")
error: Optional[str] = Field(None, description="Error message if generation failed")
class StoryAudioGenerationResponse(BaseModel):
"""Response model for audio generation."""
audio_files: List[StoryAudioResult] = Field(..., description="List of generated audio files")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")
class StoryVideoGenerationRequest(BaseModel):
"""Request model for video generation."""
scenes: List[StoryScene] = Field(..., description="List of scenes to generate video for")
image_urls: List[str] = Field(..., description="List of image URLs for each scene")
audio_urls: List[str] = Field(..., description="List of audio URLs for each scene")
story_title: Optional[str] = Field(default="Story", description="Title of the story")
fps: Optional[int] = Field(default=24, description="Frames per second for video")
transition_duration: Optional[float] = Field(default=0.5, description="Duration of transitions between scenes")
class StoryVideoResult(BaseModel):
"""Model for a generated video result."""
video_filename: str = Field(..., description="Video filename")
video_url: str = Field(..., description="Video URL")
duration: float = Field(..., description="Video duration in seconds")
fps: int = Field(..., description="Frames per second")
file_size: int = Field(..., description="Video file size in bytes")
num_scenes: int = Field(..., description="Number of scenes in the video")
error: Optional[str] = Field(None, description="Error message if generation failed")
class StoryVideoGenerationResponse(BaseModel):
"""Response model for video generation."""
video: StoryVideoResult = Field(..., description="Generated video")
success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations")

View File

@@ -323,4 +323,54 @@ class BillingHistory(Base):
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class SubscriptionRenewalHistory(Base):
"""Historical record of subscription renewals and expiration events."""
__tablename__ = "subscription_renewal_history"
id = Column(Integer, primary_key=True)
user_id = Column(String(100), nullable=False)
# Subscription Details
plan_id = Column(Integer, ForeignKey('subscription_plans.id'), nullable=False)
plan_name = Column(String(50), nullable=False)
plan_tier = Column(String(20), nullable=False) # e.g., "free", "basic", "pro", "enterprise"
# Period Information
previous_period_start = Column(DateTime, nullable=True) # Start of the previous period (if renewal)
previous_period_end = Column(DateTime, nullable=True) # End of the previous period (when it expired)
new_period_start = Column(DateTime, nullable=False) # Start of the new period (when renewed)
new_period_end = Column(DateTime, nullable=False) # End of the new period
# Billing Cycle
billing_cycle = Column(Enum(BillingCycle), nullable=False) # "monthly" or "yearly"
# Renewal Information
renewal_type = Column(String(20), nullable=False) # "new", "renewal", "upgrade", "downgrade"
renewal_count = Column(Integer, default=0) # Sequential renewal number (1st renewal, 2nd renewal, etc.)
# Previous Subscription Snapshot (before renewal)
previous_plan_name = Column(String(50), nullable=True)
previous_plan_tier = Column(String(20), nullable=True)
# Usage Summary Before Renewal (snapshot)
usage_before_renewal = Column(JSON, nullable=True) # Snapshot of usage before renewal
# Payment Information
payment_amount = Column(Float, default=0.0)
payment_status = Column(String(20), default="pending") # "pending", "paid", "failed"
payment_date = Column(DateTime, nullable=True)
stripe_invoice_id = Column(String(100), nullable=True)
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
plan = relationship("SubscriptionPlan")
# Indexes for performance
__table_args__ = (
{'mysql_engine': 'InnoDB'},
)

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,143 @@
"""
Quick diagnostic script to check Wix configuration.
Run this to verify your WIX_API_KEY is properly loaded.
Usage:
python backend/scripts/check_wix_config.py
"""
import os
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
def check_wix_config():
"""Check if Wix configuration is properly set up."""
print("\n" + "="*60)
print("🔍 WIX CONFIGURATION DIAGNOSTIC")
print("="*60 + "\n")
# 1. Check if .env file exists
env_locations = [
Path.cwd() / ".env",
Path.cwd() / "backend" / ".env",
Path.cwd() / ".env.local",
]
print("📁 Checking for .env files:")
env_file_found = False
for env_path in env_locations:
exists = env_path.exists()
status = "✅ FOUND" if exists else "❌ NOT FOUND"
print(f" {status}: {env_path}")
if exists:
env_file_found = True
if not env_file_found:
print("\n⚠️ WARNING: No .env file found!")
print(" Create a .env file in your project root.")
print("\n" + "-"*60 + "\n")
# 2. Try loading .env file
try:
from dotenv import load_dotenv
load_dotenv()
print("✅ dotenv loaded successfully")
except ImportError:
print("❌ python-dotenv not installed")
print(" Install: pip install python-dotenv")
except Exception as e:
print(f"⚠️ Error loading .env: {e}")
print("\n" + "-"*60 + "\n")
# 3. Check WIX_API_KEY environment variable
print("🔑 Checking WIX_API_KEY environment variable:")
api_key = os.getenv('WIX_API_KEY')
if not api_key:
print(" ❌ NOT FOUND")
print("\n⚠️ CRITICAL: WIX_API_KEY is not set!")
print("\nTo fix:")
print(" 1. Add this line to your .env file:")
print(" WIX_API_KEY=your_api_key_from_wix_dashboard")
print(" 2. Restart your backend server")
print(" 3. Run this script again to verify")
return False
print(" ✅ FOUND")
print(f" Length: {len(api_key)} characters")
print(f" Preview: {api_key[:30]}...")
# 4. Validate API key format
print("\n" + "-"*60 + "\n")
print("🔍 Validating API key format:")
if api_key.startswith("JWS."):
print(" ✅ Starts with 'JWS.' (correct format)")
else:
print(f" ⚠️ Doesn't start with 'JWS.' (got: {api_key[:10]}...)")
print(" This might not be a valid Wix API key")
if len(api_key) > 200:
print(f" ✅ Length looks correct ({len(api_key)} chars)")
else:
print(f" ⚠️ API key seems too short ({len(api_key)} chars)")
print(" Wix API keys are typically 500+ characters")
dot_count = api_key.count('.')
print(f" 📊 Contains {dot_count} dots (JWT tokens have 2+ dots)")
# 5. Test import of Wix services
print("\n" + "-"*60 + "\n")
print("📦 Testing Wix service imports:")
try:
from services.integrations.wix.auth_utils import get_wix_api_key
test_key = get_wix_api_key()
if test_key:
print(" ✅ auth_utils.get_wix_api_key() works")
print(f" ✅ Returned key length: {len(test_key)}")
print(f" ✅ Keys match: {test_key == api_key}")
else:
print(" ❌ auth_utils.get_wix_api_key() returned None")
print(" Even though os.getenv('WIX_API_KEY') found it!")
print(" This indicates an environment loading issue.")
except Exception as e:
print(f" ❌ Error importing: {e}")
# 6. Final summary
print("\n" + "="*60)
print("📋 SUMMARY")
print("="*60 + "\n")
if api_key and len(api_key) > 200 and api_key.startswith("JWS."):
print("✅ Configuration looks GOOD!")
print("\nNext steps:")
print(" 1. Restart your backend server")
print(" 2. Try publishing a blog post")
print(" 3. Check logs for 'Using API key' messages")
print(" 4. Verify no 403 Forbidden errors")
else:
print("❌ Configuration has ISSUES!")
print("\nPlease review the warnings above and:")
print(" 1. Ensure WIX_API_KEY is set in your .env file")
print(" 2. Verify the API key is correct (from Wix Dashboard)")
print(" 3. Restart your backend server")
print(" 4. Run this script again")
print("\n" + "="*60 + "\n")
return bool(api_key)
if __name__ == "__main__":
success = check_wix_config()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,85 @@
"""
Script to run the failure tracking migration.
Adds consecutive_failures and failure_pattern columns to task tables.
"""
import sqlite3
import os
import sys
# Add parent directory to path to import migration
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def run_migration():
"""Run the failure tracking migration."""
# Get database path
db_path = os.getenv('DATABASE_URL', 'sqlite:///alwrity.db')
# Extract path from SQLite URL if needed
if db_path.startswith('sqlite:///'):
db_path = db_path.replace('sqlite:///', '')
if not os.path.exists(db_path):
print(f"Database not found at {db_path}")
return False
print(f"Running migration on database: {db_path}")
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Read migration SQL
migration_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'database',
'migrations',
'add_failure_tracking_to_tasks.sql'
)
if not os.path.exists(migration_file):
print(f"Migration file not found: {migration_file}")
return False
with open(migration_file, 'r') as f:
migration_sql = f.read()
# Execute migration (SQLite doesn't support multiple statements in execute, so split)
statements = [s.strip() for s in migration_sql.split(';') if s.strip()]
for statement in statements:
try:
cursor.execute(statement)
print(f"✓ Executed: {statement[:50]}...")
except sqlite3.OperationalError as e:
# Column might already exist - that's okay
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
print(f"⚠ Column already exists (skipping): {statement[:50]}...")
else:
raise
conn.commit()
print("\n✅ Migration completed successfully!")
# Verify columns were added
cursor.execute("PRAGMA table_info(oauth_token_monitoring_tasks)")
columns = [row[1] for row in cursor.fetchall()]
if 'consecutive_failures' in columns and 'failure_pattern' in columns:
print("✓ Verified: consecutive_failures and failure_pattern columns exist")
else:
print("⚠ Warning: Could not verify columns were added")
conn.close()
return True
except Exception as e:
print(f"❌ Error running migration: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = run_migration()
sys.exit(0 if success else 1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB