Research component integration, Copilotkit implementation, SEO copilotkit implementation, Wix SEO metadata complete, Wix SEO metadata review

This commit is contained in:
ajaysi
2025-11-03 16:01:44 +05:30
parent de4328175d
commit e69107b07c
94 changed files with 9748 additions and 1565 deletions

View File

@@ -185,10 +185,20 @@ async def get_research_status(task_id: str) -> Dict[str, Any]:
# Outline Endpoints
@router.post("/outline/start")
async def start_outline_generation(request: BlogOutlineRequest) -> Dict[str, Any]:
async def start_outline_generation(
request: BlogOutlineRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Start an outline generation operation and return a task ID for polling."""
try:
task_id = task_manager.start_outline_task(request)
# 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="User ID not found in authentication token")
task_id = task_manager.start_outline_task(request, user_id)
return {"task_id": task_id, "status": "started"}
except Exception as e:
logger.error(f"Failed to start outline generation: {e}")
@@ -272,12 +282,22 @@ async def generate_section(request: BlogSectionRequest) -> BlogSectionResponse:
@router.post("/content/start")
async def start_content_generation(request: Dict[str, Any]) -> Dict[str, Any]:
async def start_content_generation(
request: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Start full content generation and return a task id for polling.
Accepts a payload compatible with MediumBlogGenerateRequest to minimize duplication.
"""
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="User ID not found in authentication token")
# Map dict to MediumBlogGenerateRequest for reuse
from models.blog_models import MediumBlogGenerateRequest, MediumSectionOutline, PersonaInfo
sections = [MediumSectionOutline(**s) for s in request.get("sections", [])]
@@ -293,7 +313,7 @@ async def start_content_generation(request: Dict[str, Any]) -> Dict[str, Any]:
globalTargetWords=request.get("globalTargetWords", 1000),
researchKeywords=request.get("researchKeywords") or request.get("keywords"),
)
task_id = task_manager.start_content_generation_task(req)
task_id = task_manager.start_content_generation_task(req, user_id)
return {"task_id": task_id, "status": "started"}
except Exception as e:
logger.error(f"Failed to start content generation: {e}")
@@ -307,6 +327,51 @@ async def content_generation_status(task_id: str) -> Dict[str, Any]:
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
# If task failed with subscription error, return HTTP error so frontend interceptor can catch it
if status.get('status') == 'failed' and status.get('error_status') in [429, 402]:
error_data = status.get('error_data', {}) or {}
error_status = status.get('error_status', 429)
if not isinstance(error_data, dict):
logger.warning(f"Content generation task {task_id} error_data not dict: {error_data}")
error_data = {'error': str(error_data)}
# Determine provider and usage info
stored_error_message = status.get('error', error_data.get('error'))
provider = error_data.get('provider', 'unknown')
usage_info = error_data.get('usage_info')
if not usage_info:
usage_info = {
'provider': provider,
'message': stored_error_message,
'error_type': error_data.get('error_type', 'unknown')
}
# Include any known fields from error_data
for key in ['current_tokens', 'requested_tokens', 'limit', 'current_calls']:
if key in error_data:
usage_info[key] = error_data[key]
# Build error message for detail
error_msg = error_data.get('message', stored_error_message or 'Subscription limit exceeded')
# Log the subscription error with all context
logger.warning(f"Content generation task {task_id} failed with subscription error {error_status}: {error_msg}")
logger.warning(f" Provider: {provider}, Usage Info: {usage_info}")
# Use JSONResponse to ensure detail is returned as-is, not wrapped in an array
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=error_status,
content={
'error': error_data.get('error', stored_error_message or 'Subscription limit exceeded'),
'message': error_msg,
'provider': provider,
'usage_info': usage_info
}
)
return status
except HTTPException:
raise
@@ -499,14 +564,24 @@ async def get_outline_cache_entries(limit: int = 20):
# ---------------------------
@router.post("/generate/medium/start")
async def start_medium_generation(request: MediumBlogGenerateRequest):
async def start_medium_generation(
request: MediumBlogGenerateRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Start medium-length blog generation (≤1000 words) and return a task id."""
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="User ID not found in authentication token")
# Simple server-side guard
if (request.globalTargetWords or 1000) > 1000:
raise HTTPException(status_code=400, detail="Global target words exceed 1000; use per-section generation")
task_id = task_manager.start_medium_generation_task(request)
task_id = task_manager.start_medium_generation_task(request, user_id)
return {"task_id": task_id, "status": "started"}
except HTTPException:
raise
@@ -522,6 +597,51 @@ async def medium_generation_status(task_id: str):
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
# If task failed with subscription error, return HTTP error so frontend interceptor can catch it
if status.get('status') == 'failed' and status.get('error_status') in [429, 402]:
error_data = status.get('error_data', {}) or {}
error_status = status.get('error_status', 429)
if not isinstance(error_data, dict):
logger.warning(f"Medium generation task {task_id} error_data not dict: {error_data}")
error_data = {'error': str(error_data)}
# Determine provider and usage info
stored_error_message = status.get('error', error_data.get('error'))
provider = error_data.get('provider', 'unknown')
usage_info = error_data.get('usage_info')
if not usage_info:
usage_info = {
'provider': provider,
'message': stored_error_message,
'error_type': error_data.get('error_type', 'unknown')
}
# Include any known fields from error_data
for key in ['current_tokens', 'requested_tokens', 'limit', 'current_calls']:
if key in error_data:
usage_info[key] = error_data[key]
# Build error message for detail
error_msg = error_data.get('message', stored_error_message or 'Subscription limit exceeded')
# Log the subscription error with all context
logger.warning(f"Medium generation task {task_id} failed with subscription error {error_status}: {error_msg}")
logger.warning(f" Provider: {provider}, Usage Info: {usage_info}")
# Use JSONResponse to ensure detail is returned as-is, not wrapped in an array
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=error_status,
content={
'error': error_data.get('error', stored_error_message or 'Subscription limit exceeded'),
'message': error_msg,
'provider': provider,
'usage_info': usage_info
}
)
return status
except HTTPException:
raise

View File

@@ -5,7 +5,7 @@ Provides API endpoint for analyzing blog content SEO with parallel processing
and CopilotKit integration for real-time progress updates.
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends
from pydantic import BaseModel
from typing import Dict, Any, Optional
from loguru import logger
@@ -13,6 +13,7 @@ from datetime import datetime
from services.blog_writer.seo.blog_content_seo_analyzer import BlogContentSEOAnalyzer
from services.blog_writer.core.blog_writer_service import BlogWriterService
from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/api/blog-writer/seo", tags=["Blog SEO Analysis"])
@@ -56,7 +57,10 @@ blog_writer_service = BlogWriterService()
@router.post("/analyze", response_model=SEOAnalysisResponse)
async def analyze_blog_seo(request: SEOAnalysisRequest):
async def analyze_blog_seo(
request: SEOAnalysisRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Analyze blog content for SEO optimization
@@ -69,6 +73,7 @@ async def analyze_blog_seo(request: SEOAnalysisRequest):
Args:
request: SEOAnalysisRequest containing blog content and research data
current_user: Authenticated user from middleware
Returns:
SEOAnalysisResponse with comprehensive analysis results
@@ -76,6 +81,14 @@ async def analyze_blog_seo(request: SEOAnalysisRequest):
try:
logger.info(f"Starting SEO analysis for blog content")
# 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")
# Validate request
if not request.blog_content or not request.blog_content.strip():
raise HTTPException(status_code=400, detail="Blog content is required")
@@ -91,7 +104,8 @@ async def analyze_blog_seo(request: SEOAnalysisRequest):
analysis_results = await seo_analyzer.analyze_blog_content(
blog_content=request.blog_content,
research_data=request.research_data,
blog_title=request.blog_title
blog_title=request.blog_title,
user_id=user_id
)
# Check for errors
@@ -131,7 +145,10 @@ async def analyze_blog_seo(request: SEOAnalysisRequest):
@router.post("/analyze-with-progress")
async def analyze_blog_seo_with_progress(request: SEOAnalysisRequest):
async def analyze_blog_seo_with_progress(
request: SEOAnalysisRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Analyze blog content for SEO with real-time progress updates
@@ -140,6 +157,7 @@ async def analyze_blog_seo_with_progress(request: SEOAnalysisRequest):
Args:
request: SEOAnalysisRequest containing blog content and research data
current_user: Authenticated user from middleware
Returns:
Generator yielding progress updates and final results
@@ -147,6 +165,14 @@ async def analyze_blog_seo_with_progress(request: SEOAnalysisRequest):
try:
logger.info(f"Starting SEO analysis with progress for blog content")
# 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")
# Validate request
if not request.blog_content or not request.blog_content.strip():
raise HTTPException(status_code=400, detail="Blog content is required")
@@ -209,7 +235,9 @@ async def analyze_blog_seo_with_progress(request: SEOAnalysisRequest):
# Perform actual analysis
analysis_results = await seo_analyzer.analyze_blog_content(
blog_content=request.blog_content,
research_data=request.research_data
research_data=request.research_data,
blog_title=request.blog_title,
user_id=user_id
)
# Final result

View File

@@ -88,8 +88,12 @@ class TaskManager:
response["error"] = task["error"]
if "error_status" in task:
response["error_status"] = task["error_status"]
logger.info(f"[TaskManager] get_task_status for {task_id}: Including error_status={task['error_status']} in response")
if "error_data" in task:
response["error_data"] = task["error_data"]
logger.info(f"[TaskManager] get_task_status for {task_id}: Including error_data with keys: {list(task['error_data'].keys()) if isinstance(task['error_data'], dict) else 'not-dict'}")
else:
logger.warning(f"[TaskManager] get_task_status for {task_id}: Task failed but no error_data found. Task keys: {list(task.keys())}")
return response
@@ -127,29 +131,33 @@ class TaskManager:
asyncio.create_task(self._run_research_task(task_id, request, user_id))
return task_id
def start_outline_task(self, request: BlogOutlineRequest) -> str:
def start_outline_task(self, request: BlogOutlineRequest, user_id: str) -> str:
"""Start an outline generation operation and return a task ID."""
task_id = self.create_task("outline")
# Start the outline generation operation in the background
asyncio.create_task(self._run_outline_generation_task(task_id, request))
asyncio.create_task(self._run_outline_generation_task(task_id, request, user_id))
return task_id
def start_medium_generation_task(self, request: MediumBlogGenerateRequest) -> str:
def start_medium_generation_task(self, request: MediumBlogGenerateRequest, user_id: str) -> str:
"""Start a medium (≤1000 words) full-blog generation task."""
task_id = self.create_task("medium_generation")
asyncio.create_task(self._run_medium_generation_task(task_id, request))
asyncio.create_task(self._run_medium_generation_task(task_id, request, user_id))
return task_id
def start_content_generation_task(self, request: MediumBlogGenerateRequest) -> str:
def start_content_generation_task(self, request: MediumBlogGenerateRequest, user_id: str) -> str:
"""Start content generation (full blog via sections) with provider parity.
Internally reuses medium generator pipeline for now but tracked under
distinct task_type 'content_generation' and same polling contract.
Args:
request: Content generation request
user_id: User ID (required for subscription checks and usage tracking)
"""
task_id = self.create_task("content_generation")
asyncio.create_task(self._run_medium_generation_task(task_id, request))
asyncio.create_task(self._run_medium_generation_task(task_id, request, user_id))
return task_id
async def _run_research_task(self, task_id: str, request: BlogResearchRequest, user_id: str):
@@ -205,7 +213,7 @@ class TaskManager:
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = "Research completed with unknown status"
async def _run_outline_generation_task(self, task_id: str, request: BlogOutlineRequest):
async def _run_outline_generation_task(self, task_id: str, request: BlogOutlineRequest, user_id: str):
"""Background task to run outline generation and update status with progress messages."""
try:
# Update status to running
@@ -215,21 +223,31 @@ class TaskManager:
# Send initial progress message
await self.update_progress(task_id, "🧩 Starting outline generation...")
# Run the actual outline generation with progress updates
result = await self.service.generate_outline_with_progress(request, task_id)
# Run the actual outline generation with progress updates (pass user_id for subscription checks)
result = await self.service.generate_outline_with_progress(request, task_id, user_id)
# Update status to completed
await self.update_progress(task_id, f"✅ Outline generated successfully! Created {len(result.outline)} sections with {len(result.title_options)} title options.")
self.task_storage[task_id]["status"] = "completed"
self.task_storage[task_id]["result"] = result.dict()
except HTTPException as http_error:
# Handle HTTPException (e.g., 429 subscription limit) - preserve error details for frontend
error_detail = http_error.detail
error_message = error_detail.get('message', str(error_detail)) if isinstance(error_detail, dict) else str(error_detail)
await self.update_progress(task_id, f"{error_message}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = error_message
# Store HTTP error details for frontend modal
self.task_storage[task_id]["error_status"] = http_error.status_code
self.task_storage[task_id]["error_data"] = error_detail if isinstance(error_detail, dict) else {"error": str(error_detail)}
except Exception as e:
await self.update_progress(task_id, f"❌ Outline generation failed: {str(e)}")
# Update status to failed
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest):
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest, user_id: str):
"""Background task to generate a medium blog using a single structured JSON call."""
try:
self.task_storage[task_id]["status"] = "running"
@@ -245,6 +263,7 @@ class TaskManager:
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
request,
task_id,
user_id
)
if not result or not getattr(result, "sections", None):
@@ -263,10 +282,38 @@ class TaskManager:
self.task_storage[task_id]["result"] = result.dict()
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
except Exception as e:
await self.update_progress(task_id, f"❌ Medium generation failed: {str(e)}")
except HTTPException as http_error:
# Handle HTTPException (e.g., 429 subscription limit) - preserve error details for frontend
logger.info(f"[TaskManager] Caught HTTPException in medium generation task {task_id}: status={http_error.status_code}, detail={http_error.detail}")
error_detail = http_error.detail
error_message = error_detail.get('message', str(error_detail)) if isinstance(error_detail, dict) else str(error_detail)
await self.update_progress(task_id, f"{error_message}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
self.task_storage[task_id]["error"] = error_message
# Store HTTP error details for frontend modal
self.task_storage[task_id]["error_status"] = http_error.status_code
self.task_storage[task_id]["error_data"] = error_detail if isinstance(error_detail, dict) else {"error": str(error_detail)}
logger.info(f"[TaskManager] Stored error_status={http_error.status_code} and error_data keys: {list(error_detail.keys()) if isinstance(error_detail, dict) else 'not-dict'}")
except Exception as e:
# Check if this is an HTTPException that got wrapped (can happen in async tasks)
# HTTPException has status_code and detail attributes
logger.info(f"[TaskManager] Caught Exception in medium generation task {task_id}: type={type(e).__name__}, has_status_code={hasattr(e, 'status_code')}, has_detail={hasattr(e, 'detail')}")
if hasattr(e, 'status_code') and hasattr(e, 'detail'):
# This is an HTTPException that was caught as generic Exception
logger.info(f"[TaskManager] Detected HTTPException in Exception handler: status={e.status_code}, detail={e.detail}")
error_detail = e.detail
error_message = error_detail.get('message', str(error_detail)) if isinstance(error_detail, dict) else str(error_detail)
await self.update_progress(task_id, f"{error_message}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = error_message
# Store HTTP error details for frontend modal
self.task_storage[task_id]["error_status"] = e.status_code
self.task_storage[task_id]["error_data"] = error_detail if isinstance(error_detail, dict) else {"error": str(error_detail)}
logger.info(f"[TaskManager] Stored error_status={e.status_code} and error_data keys: {list(error_detail.keys()) if isinstance(error_detail, dict) else 'not-dict'}")
else:
await self.update_progress(task_id, f"❌ Medium generation failed: {str(e)}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
# Global task manager instance

View File

@@ -12,6 +12,7 @@ from functools import lru_cache
from services.database import get_db
from services.subscription import UsageTrackingService, PricingService
from services.subscription.schema_utils import ensure_subscription_plan_columns
from middleware.auth_middleware import get_current_user
from models.subscription_models import (
APIProvider, SubscriptionPlan, UserSubscription, UsageSummary,
@@ -79,6 +80,8 @@ async def get_subscription_plans(
"""Get all available subscription plans."""
try:
# Ensure required columns exist (handles environments without migrations applied yet)
ensure_subscription_plan_columns(db)
plans = db.query(SubscriptionPlan).filter(
SubscriptionPlan.is_active == True
).order_by(SubscriptionPlan.price_monthly).all()
@@ -137,6 +140,7 @@ async def get_user_subscription(
raise HTTPException(status_code=403, detail="Access denied")
try:
ensure_subscription_plan_columns(db)
subscription = db.query(UserSubscription).filter(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
@@ -234,6 +238,7 @@ async def get_subscription_status(
raise HTTPException(status_code=403, detail="Access denied")
try:
ensure_subscription_plan_columns(db)
subscription = db.query(UserSubscription).filter(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
@@ -346,6 +351,7 @@ async def subscribe_to_plan(
raise HTTPException(status_code=403, detail="Access denied")
try:
ensure_subscription_plan_columns(db)
plan_id = subscription_data.get('plan_id')
billing_cycle = subscription_data.get('billing_cycle', 'monthly')
@@ -427,11 +433,16 @@ async def subscribe_to_plan(
logger.info(f" 📊 No usage summary found for period {current_period} (will be created on reset)")
# Clear subscription limits cache to force refresh on next check
# IMPORTANT: Do this BEFORE resetting usage to ensure cache is cleared first
try:
from services.subscription import PricingService
# Clear cache for this specific user (class-level cache shared across all instances)
cleared_count = PricingService.clear_user_cache(user_id)
logger.info(f" 🗑️ Cleared {cleared_count} subscription cache entries for user {user_id}")
# Also expire all SQLAlchemy objects to force fresh reads
db.expire_all()
logger.info(f" 🔄 Expired all SQLAlchemy objects to force fresh reads")
except Exception as cache_err:
logger.error(f" ❌ Failed to clear cache after subscribe: {cache_err}")
@@ -441,12 +452,22 @@ async def subscribe_to_plan(
usage_service = UsageTrackingService(db)
reset_result = await usage_service.reset_current_billing_period(user_id)
# Re-query usage summary from DB after reset to get fresh data
# Force commit to ensure reset is persisted
db.commit()
# Expire all SQLAlchemy objects to force fresh reads
db.expire_all()
# Re-query usage summary from DB after reset to get fresh data (fresh query)
usage_after = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
# Refresh the usage object if found to ensure we have latest data
if usage_after:
db.refresh(usage_after)
if reset_result.get('reset'):
logger.info(f" ✅ Usage counters RESET successfully")
if usage_after:
@@ -635,6 +656,7 @@ async def get_dashboard_data(
"""Get comprehensive dashboard data for usage monitoring."""
try:
ensure_subscription_plan_columns(db)
# Serve from short TTL cache to avoid hammering DB on bursts
import time
now = time.time()

View File

@@ -535,15 +535,33 @@ async def test_publish_real(payload: Dict[str, Any]) -> Dict[str, Any]:
if not member_id:
raise HTTPException(status_code=400, detail="Unable to resolve member_id from token")
# Extract SEO metadata if provided
seo_metadata = payload.get("seo_metadata")
# Extract category/tag IDs or names
# Can be either:
# - IDs: List of UUID strings
# - Names: List of name strings (will be looked up/created)
category_ids = payload.get("category_ids") or payload.get("category_names")
tag_ids = payload.get("tag_ids") or payload.get("tag_names")
# If SEO metadata has categories/tags but they weren't explicitly provided, use them
if seo_metadata:
if not category_ids and seo_metadata.get("blog_categories"):
category_ids = seo_metadata.get("blog_categories")
if not tag_ids and seo_metadata.get("blog_tags"):
tag_ids = seo_metadata.get("blog_tags")
result = wix_service.create_blog_post(
access_token=access_token,
title=payload.get("title") or "Untitled",
content=payload.get("content") or "",
cover_image_url=payload.get("cover_image_url"),
category_ids=payload.get("category_ids") or None,
tag_ids=payload.get("tag_ids") or None,
category_ids=category_ids,
tag_ids=tag_ids,
publish=bool(payload.get("publish", True)),
member_id=member_id,
seo_metadata=seo_metadata,
)
return {