Base code

This commit is contained in:
Kunthawat Greethong
2026-01-08 22:39:53 +07:00
parent 697115c61a
commit c35fa52117
2169 changed files with 626670 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# Package init for AI Blog Writer API

View File

@@ -0,0 +1,77 @@
"""
Cache Management System for Blog Writer API
Handles research and outline cache operations including statistics,
clearing, invalidation, and entry retrieval.
"""
from typing import Any, Dict, List
from loguru import logger
from services.blog_writer.blog_service import BlogWriterService
class CacheManager:
"""Manages cache operations for research and outline data."""
def __init__(self):
self.service = BlogWriterService()
def get_research_cache_stats(self) -> Dict[str, Any]:
"""Get research cache statistics."""
try:
from services.cache.research_cache import research_cache
return research_cache.get_cache_stats()
except Exception as e:
logger.error(f"Failed to get research cache stats: {e}")
raise
def clear_research_cache(self) -> Dict[str, Any]:
"""Clear the research cache."""
try:
from services.cache.research_cache import research_cache
research_cache.clear_cache()
return {"status": "success", "message": "Research cache cleared"}
except Exception as e:
logger.error(f"Failed to clear research cache: {e}")
raise
def get_outline_cache_stats(self) -> Dict[str, Any]:
"""Get outline cache statistics."""
try:
stats = self.service.get_outline_cache_stats()
return {"success": True, "stats": stats}
except Exception as e:
logger.error(f"Failed to get outline cache stats: {e}")
raise
def clear_outline_cache(self) -> Dict[str, Any]:
"""Clear all cached outline entries."""
try:
self.service.clear_outline_cache()
return {"success": True, "message": "Outline cache cleared successfully"}
except Exception as e:
logger.error(f"Failed to clear outline cache: {e}")
raise
def invalidate_outline_cache_for_keywords(self, keywords: List[str]) -> Dict[str, Any]:
"""Invalidate outline cache entries for specific keywords."""
try:
self.service.invalidate_outline_cache_for_keywords(keywords)
return {"success": True, "message": f"Invalidated cache for keywords: {keywords}"}
except Exception as e:
logger.error(f"Failed to invalidate outline cache for keywords {keywords}: {e}")
raise
def get_recent_outline_cache_entries(self, limit: int = 20) -> Dict[str, Any]:
"""Get recent outline cache entries for debugging."""
try:
entries = self.service.get_recent_outline_cache_entries(limit)
return {"success": True, "entries": entries}
except Exception as e:
logger.error(f"Failed to get recent outline cache entries: {e}")
raise
# Global cache manager instance
cache_manager = CacheManager()

View File

@@ -0,0 +1,984 @@
"""
AI Blog Writer API Router
Main router for blog writing operations including research, outline generation,
content creation, SEO analysis, and publishing.
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from loguru import logger
from middleware.auth_middleware import get_current_user
from sqlalchemy.orm import Session
from services.database import get_db as get_db_dependency
from utils.text_asset_tracker import save_and_track_text_content
from models.blog_models import (
BlogResearchRequest,
BlogResearchResponse,
BlogOutlineRequest,
BlogOutlineResponse,
BlogOutlineRefineRequest,
BlogSectionRequest,
BlogSectionResponse,
BlogOptimizeRequest,
BlogOptimizeResponse,
BlogSEOAnalyzeRequest,
BlogSEOAnalyzeResponse,
BlogSEOMetadataRequest,
BlogSEOMetadataResponse,
BlogPublishRequest,
BlogPublishResponse,
HallucinationCheckRequest,
HallucinationCheckResponse,
)
from services.blog_writer.blog_service import BlogWriterService
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
from .task_manager import task_manager
from .cache_manager import cache_manager
from models.blog_models import MediumBlogGenerateRequest
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
service = BlogWriterService()
recommendation_applier = BlogSEORecommendationApplier()
# Use the proper database dependency from services.database
get_db = get_db_dependency
# ---------------------------
# SEO Recommendation Endpoints
# ---------------------------
class RecommendationItem(BaseModel):
category: str = Field(..., description="Recommendation category, e.g. Structure")
priority: str = Field(..., description="Priority level: High | Medium | Low")
recommendation: str = Field(..., description="Action to perform")
impact: str = Field(..., description="Expected impact or rationale")
class SEOApplyRecommendationsRequest(BaseModel):
title: str = Field(..., description="Current blog title")
sections: List[Dict[str, Any]] = Field(..., description="Array of sections with id, heading, content")
outline: List[Dict[str, Any]] = Field(default_factory=list, description="Outline structure for context")
research: Dict[str, Any] = Field(default_factory=dict, description="Research data used for the blog")
recommendations: List[RecommendationItem] = Field(..., description="Actionable recommendations to apply")
persona: Dict[str, Any] = Field(default_factory=dict, description="Persona settings if available")
tone: str | None = Field(default=None, description="Desired tone override")
audience: str | None = Field(default=None, description="Target audience override")
@router.post("/seo/apply-recommendations")
async def apply_seo_recommendations(
request: SEOApplyRecommendationsRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Apply actionable SEO recommendations and return updated 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")
result = await recommendation_applier.apply_recommendations(request.dict(), user_id=user_id)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to apply recommendations"))
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to apply SEO recommendations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
async def health() -> Dict[str, Any]:
"""Health check endpoint."""
return {"status": "ok", "service": "ai_blog_writer"}
# Research Endpoints
@router.post("/research/start")
async def start_research(
request: BlogResearchRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Start a research operation and return a task ID for polling."""
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")
task_id = await task_manager.start_research_task(request, user_id)
return {"task_id": task_id, "status": "started"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start research: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/research/status/{task_id}")
async def get_research_status(task_id: str) -> Dict[str, Any]:
"""Get the status of a research operation."""
try:
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"Research 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"Research 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
}
)
logger.info(f"Research status request for {task_id}: {status['status']} with {len(status.get('progress_messages', []))} progress messages")
return status
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get research status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Outline Endpoints
@router.post("/outline/start")
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:
# 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}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/outline/status/{task_id}")
async def get_outline_status(task_id: str) -> Dict[str, Any]:
"""Get the status of an outline generation operation."""
try:
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get outline status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/outline/refine", response_model=BlogOutlineResponse)
async def refine_outline(request: BlogOutlineRefineRequest) -> BlogOutlineResponse:
"""Refine an existing outline with AI improvements."""
try:
return await service.refine_outline(request)
except Exception as e:
logger.error(f"Failed to refine outline: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/outline/enhance-section")
async def enhance_section(section_data: Dict[str, Any], focus: str = "general improvement"):
"""Enhance a specific section with AI improvements."""
try:
from models.blog_models import BlogOutlineSection
section = BlogOutlineSection(**section_data)
enhanced_section = await service.enhance_section_with_ai(section, focus)
return enhanced_section.dict()
except Exception as e:
logger.error(f"Failed to enhance section: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/outline/optimize")
async def optimize_outline(outline_data: Dict[str, Any], focus: str = "general optimization"):
"""Optimize entire outline for better flow, SEO, and engagement."""
try:
from models.blog_models import BlogOutlineSection
outline = [BlogOutlineSection(**section) for section in outline_data.get('outline', [])]
optimized_outline = await service.optimize_outline_with_ai(outline, focus)
return {"outline": [section.dict() for section in optimized_outline]}
except Exception as e:
logger.error(f"Failed to optimize outline: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/outline/rebalance")
async def rebalance_outline(outline_data: Dict[str, Any], target_words: int = 1500):
"""Rebalance word count distribution across outline sections."""
try:
from models.blog_models import BlogOutlineSection
outline = [BlogOutlineSection(**section) for section in outline_data.get('outline', [])]
rebalanced_outline = service.rebalance_word_counts(outline, target_words)
return {"outline": [section.dict() for section in rebalanced_outline]}
except Exception as e:
logger.error(f"Failed to rebalance outline: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Content Generation Endpoints
@router.post("/section/generate", response_model=BlogSectionResponse)
async def generate_section(
request: BlogSectionRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db)
) -> BlogSectionResponse:
"""Generate content for a specific section."""
try:
response = await service.generate_section(request)
# Save and track text content (non-blocking)
if response.markdown:
try:
user_id = str(current_user.get('id', '')) if current_user else None
if user_id:
section_heading = getattr(request, 'section_heading', getattr(request, 'heading', 'Section'))
save_and_track_text_content(
db=db,
user_id=user_id,
content=response.markdown,
source_module="blog_writer",
title=f"Blog Section: {section_heading[:60]}",
description=f"Blog section content",
prompt=f"Section: {section_heading}\nKeywords: {getattr(request, 'keywords', [])}",
tags=["blog", "section", "content"],
asset_metadata={
"section_id": getattr(request, 'section_id', None),
"word_count": len(response.markdown.split()),
},
subdirectory="sections",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track blog section asset: {track_error}")
return response
except Exception as e:
logger.error(f"Failed to generate section: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/content/start")
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", [])]
persona = None
if request.get("persona"):
persona = PersonaInfo(**request.get("persona"))
req = MediumBlogGenerateRequest(
title=request.get("title", "Untitled Blog"),
sections=sections,
persona=persona,
tone=request.get("tone"),
audience=request.get("audience"),
globalTargetWords=request.get("globalTargetWords", 1000),
researchKeywords=request.get("researchKeywords") or request.get("keywords"),
)
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}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/content/status/{task_id}")
async def content_generation_status(
task_id: str,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""Poll status for content generation task."""
try:
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
# Track blog content when task completes (non-blocking)
if status.get('status') == 'completed' and status.get('result'):
try:
result = status.get('result', {})
if result.get('sections') and len(result.get('sections', [])) > 0:
user_id = str(current_user.get('id', '')) if current_user else None
if user_id:
# Combine all sections into full blog content
blog_content = f"# {result.get('title', 'Untitled Blog')}\n\n"
for section in result.get('sections', []):
blog_content += f"\n## {section.get('heading', 'Section')}\n\n{section.get('content', '')}\n\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=blog_content,
source_module="blog_writer",
title=f"Blog: {result.get('title', 'Untitled Blog')[:60]}",
description=f"Complete blog post with {len(result.get('sections', []))} sections",
prompt=f"Title: {result.get('title', 'Untitled')}\nSections: {len(result.get('sections', []))}",
tags=["blog", "complete", "content"],
asset_metadata={
"section_count": len(result.get('sections', [])),
"model": result.get('model'),
},
subdirectory="complete",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track blog content asset: {track_error}")
# 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
except Exception as e:
logger.error(f"Failed to get content generation status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/section/{section_id}/continuity")
async def get_section_continuity(section_id: str) -> Dict[str, Any]:
"""Fetch last computed continuity metrics for a section (if available)."""
try:
# Access the in-memory continuity from the generator
gen = service.content_generator
# Find the last stored summary for the given section id
# For now, expose the most recent metrics if the section was just generated
# We keep a small in-memory snapshot on the generator object
continuity: Dict[str, Any] = getattr(gen, "_last_continuity", {})
metrics = continuity.get(section_id)
return {"section_id": section_id, "continuity_metrics": metrics}
except Exception as e:
logger.error(f"Failed to get section continuity for {section_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/flow-analysis/basic")
async def analyze_flow_basic(request: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze flow metrics for entire blog using single AI call (cost-effective)."""
try:
result = await service.analyze_flow_basic(request)
return result
except Exception as e:
logger.error(f"Failed to perform basic flow analysis: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/flow-analysis/advanced")
async def analyze_flow_advanced(request: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze flow metrics for each section individually (detailed but expensive)."""
try:
result = await service.analyze_flow_advanced(request)
return result
except Exception as e:
logger.error(f"Failed to perform advanced flow analysis: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/section/optimize", response_model=BlogOptimizeResponse)
async def optimize_section(
request: BlogOptimizeRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db)
) -> BlogOptimizeResponse:
"""Optimize a specific section for better quality and engagement."""
try:
response = await service.optimize_section(request)
# Save and track text content (non-blocking)
if response.optimized:
try:
user_id = str(current_user.get('id', '')) if current_user else None
if user_id:
save_and_track_text_content(
db=db,
user_id=user_id,
content=response.optimized,
source_module="blog_writer",
title=f"Optimized Blog Section",
description=f"Optimized blog section content",
prompt=f"Original Content: {request.content[:200]}\nGoals: {request.goals}",
tags=["blog", "section", "optimized"],
asset_metadata={
"optimization_goals": request.goals,
"word_count": len(response.optimized.split()),
},
subdirectory="sections/optimized",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track optimized blog section asset: {track_error}")
return response
except Exception as e:
logger.error(f"Failed to optimize section: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Quality Assurance Endpoints
@router.post("/quality/hallucination-check", response_model=HallucinationCheckResponse)
async def hallucination_check(request: HallucinationCheckRequest) -> HallucinationCheckResponse:
"""Check content for potential hallucinations and factual inaccuracies."""
try:
return await service.hallucination_check(request)
except Exception as e:
logger.error(f"Failed to perform hallucination check: {e}")
raise HTTPException(status_code=500, detail=str(e))
# SEO Endpoints
@router.post("/seo/analyze", response_model=BlogSEOAnalyzeResponse)
async def seo_analyze(
request: BlogSEOAnalyzeRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
) -> BlogSEOAnalyzeResponse:
"""Analyze content for SEO optimization opportunities."""
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")
return await service.seo_analyze(request, user_id=user_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to perform SEO analysis: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/seo/metadata", response_model=BlogSEOMetadataResponse)
async def seo_metadata(
request: BlogSEOMetadataRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
) -> BlogSEOMetadataResponse:
"""Generate SEO metadata for the blog post."""
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")
return await service.seo_metadata(request, user_id=user_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate SEO metadata: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Publishing Endpoints
@router.post("/publish", response_model=BlogPublishResponse)
async def publish(request: BlogPublishRequest) -> BlogPublishResponse:
"""Publish the blog post to the specified platform."""
try:
return await service.publish(request)
except Exception as e:
logger.error(f"Failed to publish blog: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Cache Management Endpoints
@router.get("/cache/stats")
async def get_cache_stats() -> Dict[str, Any]:
"""Get research cache statistics."""
try:
return cache_manager.get_research_cache_stats()
except Exception as e:
logger.error(f"Failed to get cache stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/cache/clear")
async def clear_cache() -> Dict[str, Any]:
"""Clear the research cache."""
try:
return cache_manager.clear_research_cache()
except Exception as e:
logger.error(f"Failed to clear cache: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/cache/outline/stats")
async def get_outline_cache_stats():
"""Get outline cache statistics."""
try:
return cache_manager.get_outline_cache_stats()
except Exception as e:
logger.error(f"Failed to get outline cache stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/cache/outline/clear")
async def clear_outline_cache():
"""Clear all cached outline entries."""
try:
return cache_manager.clear_outline_cache()
except Exception as e:
logger.error(f"Failed to clear outline cache: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cache/outline/invalidate")
async def invalidate_outline_cache(request: Dict[str, List[str]]):
"""Invalidate outline cache entries for specific keywords."""
try:
return cache_manager.invalidate_outline_cache_for_keywords(request["keywords"])
except Exception as e:
logger.error(f"Failed to invalidate outline cache: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/cache/outline/entries")
async def get_outline_cache_entries(limit: int = 20):
"""Get recent outline cache entries for debugging."""
try:
return cache_manager.get_recent_outline_cache_entries(limit)
except Exception as e:
logger.error(f"Failed to get outline cache entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------
# Medium Blog Generation API
# ---------------------------
@router.post("/generate/medium/start")
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, user_id)
return {"task_id": task_id, "status": "started"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start medium generation: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/generate/medium/status/{task_id}")
async def medium_generation_status(
task_id: str,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Poll status for medium blog generation task."""
try:
status = await task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
# Track blog content when task completes (non-blocking)
if status.get('status') == 'completed' and status.get('result'):
try:
result = status.get('result', {})
if result.get('sections') and len(result.get('sections', [])) > 0:
user_id = str(current_user.get('id', '')) if current_user else None
if user_id:
# Combine all sections into full blog content
blog_content = f"# {result.get('title', 'Untitled Blog')}\n\n"
for section in result.get('sections', []):
blog_content += f"\n## {section.get('heading', 'Section')}\n\n{section.get('content', '')}\n\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=blog_content,
source_module="blog_writer",
title=f"Medium Blog: {result.get('title', 'Untitled Blog')[:60]}",
description=f"Medium-length blog post with {len(result.get('sections', []))} sections",
prompt=f"Title: {result.get('title', 'Untitled')}\nSections: {len(result.get('sections', []))}",
tags=["blog", "medium", "complete"],
asset_metadata={
"section_count": len(result.get('sections', [])),
"model": result.get('model'),
"generation_time_ms": result.get('generation_time_ms'),
},
subdirectory="medium",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track medium blog asset: {track_error}")
# 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
except Exception as e:
logger.error(f"Failed to get medium generation status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/rewrite/start")
async def start_blog_rewrite(request: Dict[str, Any]) -> Dict[str, Any]:
"""Start blog rewrite task with user feedback."""
try:
task_id = service.start_blog_rewrite(request)
return {"task_id": task_id, "status": "started"}
except Exception as e:
logger.error(f"Failed to start blog rewrite: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rewrite/status/{task_id}")
async def rewrite_status(task_id: str):
"""Poll status for blog rewrite task."""
try:
status = await service.task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status
except HTTPException:
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),
db: Session = Depends(get_db)
) -> 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
)
# Save and track titles (non-blocking)
if titles and len(titles) > 0:
try:
titles_content = "# SEO Blog Titles\n\n" + "\n".join([f"{i+1}. {title}" for i, title in enumerate(titles)])
save_and_track_text_content(
db=db,
user_id=user_id,
content=titles_content,
source_module="blog_writer",
title=f"SEO Blog Titles: {primary_keywords[0] if primary_keywords else 'Blog'}",
description=f"SEO-optimized blog title suggestions",
prompt=f"Primary Keywords: {primary_keywords}\nSearch Intent: {search_intent}\nWord Count: {word_count}",
tags=["blog", "titles", "seo"],
asset_metadata={
"title_count": len(titles),
"primary_keywords": primary_keywords,
"search_intent": search_intent,
},
subdirectory="titles",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track SEO titles asset: {track_error}")
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),
db: Session = Depends(get_db)
) -> 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
)
# Save and track introductions (non-blocking)
if introductions and len(introductions) > 0:
try:
intro_content = f"# Blog Introductions for: {blog_title}\n\n"
for i, intro in enumerate(introductions, 1):
intro_content += f"## Introduction {i}\n\n{intro}\n\n"
save_and_track_text_content(
db=db,
user_id=user_id,
content=intro_content,
source_module="blog_writer",
title=f"Blog Introductions: {blog_title[:60]}",
description=f"Blog introduction variations",
prompt=f"Blog Title: {blog_title}\nPrimary Keywords: {primary_keywords}\nSearch Intent: {search_intent}",
tags=["blog", "introductions"],
asset_metadata={
"introduction_count": len(introductions),
"blog_title": blog_title,
"search_intent": search_intent,
},
subdirectory="introductions",
file_extension=".md"
)
except Exception as track_error:
logger.warning(f"Failed to track blog introductions asset: {track_error}")
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

@@ -0,0 +1,311 @@
"""
Blog Writer SEO Analysis API Endpoint
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, Depends
from pydantic import BaseModel
from typing import Dict, Any, Optional
from loguru import logger
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"])
class SEOAnalysisRequest(BaseModel):
"""Request model for SEO analysis"""
blog_content: str
blog_title: Optional[str] = None
research_data: Dict[str, Any]
user_id: Optional[str] = None
session_id: Optional[str] = None
class SEOAnalysisResponse(BaseModel):
"""Response model for SEO analysis"""
success: bool
analysis_id: str
overall_score: float
category_scores: Dict[str, float]
analysis_summary: Dict[str, Any]
actionable_recommendations: list
detailed_analysis: Optional[Dict[str, Any]] = None
visualization_data: Optional[Dict[str, Any]] = None
generated_at: str
error: Optional[str] = None
class SEOAnalysisProgress(BaseModel):
"""Progress update model for real-time updates"""
analysis_id: str
stage: str
progress: int
message: str
timestamp: str
# Initialize analyzer
seo_analyzer = BlogContentSEOAnalyzer()
blog_writer_service = BlogWriterService()
@router.post("/analyze", response_model=SEOAnalysisResponse)
async def analyze_blog_seo(
request: SEOAnalysisRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Analyze blog content for SEO optimization
This endpoint performs comprehensive SEO analysis including:
- Content structure analysis
- Keyword optimization analysis
- Readability assessment
- Content quality evaluation
- AI-powered insights generation
Args:
request: SEOAnalysisRequest containing blog content and research data
current_user: Authenticated user from middleware
Returns:
SEOAnalysisResponse with comprehensive analysis results
"""
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")
if not request.research_data:
raise HTTPException(status_code=400, detail="Research data is required")
# Generate analysis ID
import uuid
analysis_id = str(uuid.uuid4())
# Perform SEO analysis
analysis_results = await seo_analyzer.analyze_blog_content(
blog_content=request.blog_content,
research_data=request.research_data,
blog_title=request.blog_title,
user_id=user_id
)
# Check for errors
if 'error' in analysis_results:
logger.error(f"SEO analysis failed: {analysis_results['error']}")
return SEOAnalysisResponse(
success=False,
analysis_id=analysis_id,
overall_score=0,
category_scores={},
analysis_summary={},
actionable_recommendations=[],
detailed_analysis=None,
visualization_data=None,
generated_at=analysis_results.get('generated_at', ''),
error=analysis_results['error']
)
# Return successful response
return SEOAnalysisResponse(
success=True,
analysis_id=analysis_id,
overall_score=analysis_results.get('overall_score', 0),
category_scores=analysis_results.get('category_scores', {}),
analysis_summary=analysis_results.get('analysis_summary', {}),
actionable_recommendations=analysis_results.get('actionable_recommendations', []),
detailed_analysis=analysis_results.get('detailed_analysis'),
visualization_data=analysis_results.get('visualization_data'),
generated_at=analysis_results.get('generated_at', '')
)
except HTTPException:
raise
except Exception as e:
logger.error(f"SEO analysis endpoint error: {e}")
raise HTTPException(status_code=500, detail=f"SEO analysis failed: {str(e)}")
@router.post("/analyze-with-progress")
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
This endpoint provides real-time progress updates for CopilotKit integration.
It returns a stream of progress updates and final results.
Args:
request: SEOAnalysisRequest containing blog content and research data
current_user: Authenticated user from middleware
Returns:
Generator yielding progress updates and final results
"""
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")
if not request.research_data:
raise HTTPException(status_code=400, detail="Research data is required")
# Generate analysis ID
import uuid
analysis_id = str(uuid.uuid4())
# Yield progress updates
async def progress_generator():
try:
# Stage 1: Initialization
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="initialization",
progress=10,
message="Initializing SEO analysis...",
timestamp=datetime.utcnow().isoformat()
)
# Stage 2: Keyword extraction
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="keyword_extraction",
progress=20,
message="Extracting keywords from research data...",
timestamp=datetime.utcnow().isoformat()
)
# Stage 3: Non-AI analysis
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="non_ai_analysis",
progress=40,
message="Running content structure and readability analysis...",
timestamp=datetime.utcnow().isoformat()
)
# Stage 4: AI analysis
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="ai_analysis",
progress=70,
message="Generating AI-powered insights...",
timestamp=datetime.utcnow().isoformat()
)
# Stage 5: Results compilation
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="compilation",
progress=90,
message="Compiling analysis results...",
timestamp=datetime.utcnow().isoformat()
)
# Perform actual analysis
analysis_results = await seo_analyzer.analyze_blog_content(
blog_content=request.blog_content,
research_data=request.research_data,
blog_title=request.blog_title,
user_id=user_id
)
# Final result
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="completed",
progress=100,
message="SEO analysis completed successfully!",
timestamp=datetime.utcnow().isoformat()
)
# Yield final results (can't return in async generator)
yield analysis_results
except Exception as e:
logger.error(f"Progress generator error: {e}")
yield SEOAnalysisProgress(
analysis_id=analysis_id,
stage="error",
progress=0,
message=f"Analysis failed: {str(e)}",
timestamp=datetime.utcnow().isoformat()
)
raise
return progress_generator()
except HTTPException:
raise
except Exception as e:
logger.error(f"SEO analysis with progress endpoint error: {e}")
raise HTTPException(status_code=500, detail=f"SEO analysis failed: {str(e)}")
@router.get("/analysis/{analysis_id}")
async def get_analysis_result(analysis_id: str):
"""
Get SEO analysis result by ID
Args:
analysis_id: Unique identifier for the analysis
Returns:
SEO analysis results
"""
try:
# In a real implementation, you would store results in a database
# For now, we'll return a placeholder
logger.info(f"Retrieving SEO analysis result for ID: {analysis_id}")
return {
"analysis_id": analysis_id,
"status": "completed",
"message": "Analysis results retrieved successfully"
}
except Exception as e:
logger.error(f"Get analysis result error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to retrieve analysis result: {str(e)}")
@router.get("/health")
async def health_check():
"""Health check endpoint for SEO analysis service"""
return {
"status": "healthy",
"service": "blog-seo-analysis",
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -0,0 +1,324 @@
"""
Task Management System for Blog Writer API
Handles background task execution, status tracking, and progress updates
for research and outline generation operations.
Now uses database-backed persistence for reliability and recovery.
"""
import asyncio
import uuid
from datetime import datetime
from typing import Any, Dict, List
from fastapi import HTTPException
from loguru import logger
from models.blog_models import (
BlogResearchRequest,
BlogOutlineRequest,
MediumBlogGenerateRequest,
MediumBlogGenerateResult,
)
from services.blog_writer.blog_service import BlogWriterService
from services.blog_writer.database_task_manager import DatabaseTaskManager
from utils.text_asset_tracker import save_and_track_text_content
class TaskManager:
"""Manages background tasks for research and outline generation."""
def __init__(self, db_connection=None):
# Fallback to in-memory storage if no database connection
if db_connection:
self.db_manager = DatabaseTaskManager(db_connection)
self.use_database = True
else:
self.task_storage: Dict[str, Dict[str, Any]] = {}
self.service = BlogWriterService()
self.use_database = False
logger.warning("No database connection provided, using in-memory task storage")
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():
if (current_time - task_data["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]
def create_task(self, task_type: str = "general") -> 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
}
return task_id
async def get_task_status(self, task_id: str) -> Dict[str, Any]:
"""Get the status of a task."""
if self.use_database:
return await self.db_manager.get_task_status(task_id)
else:
self.cleanup_old_tasks()
if task_id not in self.task_storage:
return None
task = self.task_storage[task_id]
response = {
"task_id": task_id,
"status": task["status"],
"created_at": task["created_at"].isoformat(),
"progress_messages": task.get("progress_messages", [])
}
if task["status"] == "completed":
response["result"] = task["result"]
elif task["status"] == "failed":
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
async def update_progress(self, task_id: str, message: str, percentage: float = None):
"""Update progress message for a task."""
if self.use_database:
await self.db_manager.update_progress(task_id, message, percentage)
else:
if task_id in self.task_storage:
if "progress_messages" not in self.task_storage[task_id]:
self.task_storage[task_id]["progress_messages"] = []
progress_entry = {
"timestamp": datetime.now().isoformat(),
"message": message
}
self.task_storage[task_id]["progress_messages"].append(progress_entry)
# Keep only last 10 progress messages to prevent memory bloat
if len(self.task_storage[task_id]["progress_messages"]) > 10:
self.task_storage[task_id]["progress_messages"] = self.task_storage[task_id]["progress_messages"][-10:]
logger.info(f"Progress update for task {task_id}: {message}")
async def start_research_task(self, request: BlogResearchRequest, user_id: str) -> str:
"""Start a research operation and return a task ID."""
if self.use_database:
return await self.db_manager.start_research_task(request, user_id)
else:
task_id = self.create_task("research")
# Store user_id in task for subscription checks
if task_id in self.task_storage:
self.task_storage[task_id]["user_id"] = user_id
# Start the research operation in the background
asyncio.create_task(self._run_research_task(task_id, request, user_id))
return task_id
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, user_id))
return task_id
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, user_id))
return task_id
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, user_id))
return task_id
async def _run_research_task(self, task_id: str, request: BlogResearchRequest, user_id: str):
"""Background task to run research and update status with progress messages."""
try:
# Update status to running
self.task_storage[task_id]["status"] = "running"
self.task_storage[task_id]["progress_messages"] = []
# Send initial progress message
await self.update_progress(task_id, "🔍 Starting research operation...")
# Check cache first
await self.update_progress(task_id, "📋 Checking cache for existing research...")
# Run the actual research with progress updates (pass user_id for subscription checks)
result = await self.service.research_with_progress(request, task_id, user_id)
# Check if research failed gracefully
if not result.success:
await self.update_progress(task_id, f"❌ Research failed: {result.error_message or 'Unknown error'}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = result.error_message or "Research failed"
else:
await self.update_progress(task_id, f"✅ Research completed successfully! Found {len(result.sources)} sources and {len(result.search_queries or [])} search queries.")
# Update status to completed
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"❌ Research failed with error: {str(e)}")
# Update status to failed
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
# Ensure we always send a final completion message
finally:
if task_id in self.task_storage:
current_status = self.task_storage[task_id]["status"]
if current_status not in ["completed", "failed"]:
# Force completion if somehow we didn't set a final status
await self.update_progress(task_id, "⚠️ Research operation completed with unknown status")
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, user_id: str):
"""Background task to run outline generation and update status with progress messages."""
try:
# Update status to running
self.task_storage[task_id]["status"] = "running"
self.task_storage[task_id]["progress_messages"] = []
# Send initial progress message
await self.update_progress(task_id, "🧩 Starting outline generation...")
# 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, user_id: str):
"""Background task to generate a medium blog using a single structured JSON call."""
try:
self.task_storage[task_id]["status"] = "running"
self.task_storage[task_id]["progress_messages"] = []
await self.update_progress(task_id, "📦 Packaging outline and metadata...")
# Basic guard: respect global target words
total_target = int(request.globalTargetWords or 1000)
if total_target > 1000:
raise ValueError("Global target words exceed 1000; medium generation not allowed")
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
request,
task_id,
user_id
)
if not result or not getattr(result, "sections", None):
raise ValueError("Empty generation result from model")
# Check if result came from cache
cache_hit = getattr(result, 'cache_hit', False)
if cache_hit:
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
else:
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
# Mark completed
self.task_storage[task_id]["status"] = "completed"
self.task_storage[task_id]["result"] = result.dict()
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
# Note: Blog content tracking is handled in the status endpoint
# to ensure we have proper database session and user context
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"] = 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
task_manager = TaskManager()