Updated SEO Analysis Modal
This commit is contained in:
@@ -179,6 +179,28 @@ async def get_section_continuity(section_id: str) -> Dict[str, Any]:
|
||||
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) -> BlogOptimizeResponse:
|
||||
"""Optimize a specific section for better quality and engagement."""
|
||||
@@ -326,4 +348,28 @@ async def medium_generation_status(task_id: str):
|
||||
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 = 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))
|
||||
275
backend/api/blog_writer/seo_analysis.py
Normal file
275
backend/api/blog_writer/seo_analysis.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/blog-writer/seo", tags=["Blog SEO Analysis"])
|
||||
|
||||
|
||||
class SEOAnalysisRequest(BaseModel):
|
||||
"""Request model for SEO analysis"""
|
||||
blog_content: str
|
||||
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
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
Returns:
|
||||
SEOAnalysisResponse with comprehensive analysis results
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting SEO analysis for blog content")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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=[],
|
||||
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', []),
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
Returns:
|
||||
Generator yielding progress updates and final results
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting SEO analysis with progress for blog content")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +466,13 @@ try:
|
||||
except Exception as e:
|
||||
logger.warning(f"AI Blog Writer router not mounted: {e}")
|
||||
|
||||
# Include Blog Writer SEO Analysis router (comprehensive SEO analysis)
|
||||
try:
|
||||
from api.blog_writer.seo_analysis import router as blog_seo_analysis_router
|
||||
app.include_router(blog_seo_analysis_router)
|
||||
except Exception as e:
|
||||
logger.warning(f"Blog Writer SEO Analysis router not mounted: {e}")
|
||||
|
||||
# Include persona router
|
||||
from api.persona_routes import router as persona_router
|
||||
app.include_router(persona_router)
|
||||
|
||||
@@ -163,6 +163,7 @@ class BlogOptimizeResponse(BaseModel):
|
||||
class BlogSEOAnalyzeRequest(BaseModel):
|
||||
content: str
|
||||
keywords: List[str] = []
|
||||
research_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BlogSEOAnalyzeResponse(BaseModel):
|
||||
|
||||
209
backend/services/blog_writer/content/blog_rewriter.py
Normal file
209
backend/services/blog_writer/content/blog_rewriter.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Blog Rewriter Service
|
||||
|
||||
Handles blog rewriting based on user feedback using structured AI calls.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
|
||||
|
||||
class BlogRewriter:
|
||||
"""Service for rewriting blog content based on user feedback."""
|
||||
|
||||
def __init__(self, task_manager):
|
||||
self.task_manager = task_manager
|
||||
|
||||
def start_blog_rewrite(self, request: Dict[str, Any]) -> str:
|
||||
"""Start blog rewrite task with user feedback."""
|
||||
try:
|
||||
# Extract request data
|
||||
title = request.get("title", "Untitled Blog")
|
||||
sections = request.get("sections", [])
|
||||
research = request.get("research", {})
|
||||
outline = request.get("outline", [])
|
||||
feedback = request.get("feedback", "")
|
||||
tone = request.get("tone")
|
||||
audience = request.get("audience")
|
||||
focus = request.get("focus")
|
||||
|
||||
if not sections:
|
||||
raise ValueError("No sections provided for rewrite")
|
||||
|
||||
if not feedback or len(feedback.strip()) < 10:
|
||||
raise ValueError("Feedback is required and must be at least 10 characters")
|
||||
|
||||
# Create task for rewrite
|
||||
task_id = f"rewrite_{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Start the rewrite task
|
||||
self.task_manager.start_task(
|
||||
task_id,
|
||||
self._execute_blog_rewrite,
|
||||
title=title,
|
||||
sections=sections,
|
||||
research=research,
|
||||
outline=outline,
|
||||
feedback=feedback,
|
||||
tone=tone,
|
||||
audience=audience,
|
||||
focus=focus
|
||||
)
|
||||
|
||||
logger.info(f"Blog rewrite task started: {task_id}")
|
||||
return task_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start blog rewrite: {e}")
|
||||
raise
|
||||
|
||||
async def _execute_blog_rewrite(self, task_id: str, **kwargs):
|
||||
"""Execute the blog rewrite task."""
|
||||
try:
|
||||
title = kwargs.get("title", "Untitled Blog")
|
||||
sections = kwargs.get("sections", [])
|
||||
research = kwargs.get("research", {})
|
||||
outline = kwargs.get("outline", [])
|
||||
feedback = kwargs.get("feedback", "")
|
||||
tone = kwargs.get("tone")
|
||||
audience = kwargs.get("audience")
|
||||
focus = kwargs.get("focus")
|
||||
|
||||
# Update task status
|
||||
self.task_manager.update_task_status(task_id, "processing", "Analyzing current content and feedback...")
|
||||
|
||||
# Build rewrite prompt with user feedback
|
||||
system_prompt = f"""You are an expert blog writer tasked with rewriting content based on user feedback.
|
||||
|
||||
Current Blog Title: {title}
|
||||
User Feedback: {feedback}
|
||||
{f"Desired Tone: {tone}" if tone else ""}
|
||||
{f"Target Audience: {audience}" if audience else ""}
|
||||
{f"Focus Area: {focus}" if focus else ""}
|
||||
|
||||
Your task is to rewrite the blog content to address the user's feedback while maintaining the core structure and research insights."""
|
||||
|
||||
# Prepare content for rewrite
|
||||
full_content = f"Title: {title}\n\n"
|
||||
for section in sections:
|
||||
full_content += f"Section: {section.get('heading', 'Untitled')}\n"
|
||||
full_content += f"Content: {section.get('content', '')}\n\n"
|
||||
|
||||
# Create rewrite prompt
|
||||
rewrite_prompt = f"""
|
||||
Based on the user feedback and current blog content, rewrite the blog to address their concerns and preferences.
|
||||
|
||||
Current Content:
|
||||
{full_content}
|
||||
|
||||
User Feedback: {feedback}
|
||||
{f"Desired Tone: {tone}" if tone else ""}
|
||||
{f"Target Audience: {audience}" if audience else ""}
|
||||
{f"Focus Area: {focus}" if focus else ""}
|
||||
|
||||
Please rewrite the blog content in the following JSON format:
|
||||
{{
|
||||
"title": "New or improved blog title",
|
||||
"sections": [
|
||||
{{
|
||||
"id": "section_id",
|
||||
"heading": "Section heading",
|
||||
"content": "Rewritten section content"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
1. Address the user's feedback directly
|
||||
2. Maintain the research insights and factual accuracy
|
||||
3. Improve flow, clarity, and engagement
|
||||
4. Keep the same section structure unless feedback suggests otherwise
|
||||
5. Ensure content is well-formatted with proper paragraphs
|
||||
"""
|
||||
|
||||
# Update task status
|
||||
self.task_manager.update_task_status(task_id, "processing", "Generating rewritten content...")
|
||||
|
||||
# Use structured JSON generation
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"content": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = gemini_structured_json_response(
|
||||
prompt=rewrite_prompt,
|
||||
schema=schema,
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
logger.info(f"Gemini response for rewrite task {task_id}: {result}")
|
||||
|
||||
# Check if we have a valid result - handle both multi-section and single-section formats
|
||||
is_valid_multi_section = result and not result.get("error") and result.get("title") and result.get("sections")
|
||||
is_valid_single_section = result and not result.get("error") and (result.get("heading") or result.get("title")) and result.get("content")
|
||||
|
||||
if is_valid_multi_section or is_valid_single_section:
|
||||
# If single section format, convert to multi-section format for consistency
|
||||
if is_valid_single_section and not is_valid_multi_section:
|
||||
# Convert single section to multi-section format
|
||||
converted_result = {
|
||||
"title": result.get("heading") or result.get("title") or "Rewritten Blog",
|
||||
"sections": [
|
||||
{
|
||||
"id": result.get("id") or "section_1",
|
||||
"heading": result.get("heading") or "Main Content",
|
||||
"content": result.get("content", "")
|
||||
}
|
||||
]
|
||||
}
|
||||
result = converted_result
|
||||
logger.info(f"Converted single section response to multi-section format for task {task_id}")
|
||||
|
||||
# Update task status with success
|
||||
self.task_manager.update_task_status(
|
||||
task_id,
|
||||
"completed",
|
||||
"Blog rewrite completed successfully!",
|
||||
result=result
|
||||
)
|
||||
logger.info(f"Blog rewrite completed successfully: {task_id}")
|
||||
else:
|
||||
# More detailed error handling
|
||||
if not result:
|
||||
error_msg = "No response from AI"
|
||||
elif result.get("error"):
|
||||
error_msg = f"AI error: {result.get('error')}"
|
||||
elif not (result.get("title") or result.get("heading")):
|
||||
error_msg = "AI response missing title/heading"
|
||||
elif not (result.get("sections") or result.get("content")):
|
||||
error_msg = "AI response missing sections/content"
|
||||
else:
|
||||
error_msg = "AI response has invalid structure"
|
||||
|
||||
self.task_manager.update_task_status(task_id, "failed", f"Rewrite failed: {error_msg}")
|
||||
logger.error(f"Blog rewrite failed: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Blog rewrite error: {str(e)}"
|
||||
self.task_manager.update_task_status(task_id, "failed", error_msg)
|
||||
logger.error(f"Blog rewrite task failed: {e}")
|
||||
raise
|
||||
237
backend/services/blog_writer/content/medium_blog_generator.py
Normal file
237
backend/services/blog_writer/content/medium_blog_generator.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Medium Blog Generator Service
|
||||
|
||||
Handles generation of medium-length blogs (≤1000 words) using structured AI calls.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
from models.blog_models import (
|
||||
MediumBlogGenerateRequest,
|
||||
MediumBlogGenerateResult,
|
||||
MediumGeneratedSection,
|
||||
ResearchSource,
|
||||
)
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.cache.persistent_content_cache import persistent_content_cache
|
||||
|
||||
|
||||
class MediumBlogGenerator:
|
||||
"""Service for generating medium-length blog content using structured AI calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = persistent_content_cache
|
||||
|
||||
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str) -> MediumBlogGenerateResult:
|
||||
"""Use Gemini structured JSON to generate a medium-length blog in one call."""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
# Prepare sections data for cache key generation
|
||||
sections_for_cache = []
|
||||
for s in req.sections:
|
||||
sections_for_cache.append({
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
})
|
||||
|
||||
# Check cache first
|
||||
cached_result = self.cache.get_cached_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone,
|
||||
audience=req.audience
|
||||
)
|
||||
|
||||
if cached_result:
|
||||
logger.info(f"Using cached content for keywords: {req.researchKeywords} (saved expensive generation)")
|
||||
# Add cache hit marker to distinguish from fresh generation
|
||||
cached_result['generation_time_ms'] = 0 # Mark as cache hit
|
||||
cached_result['cache_hit'] = True
|
||||
return MediumBlogGenerateResult(**cached_result)
|
||||
|
||||
# Cache miss - proceed with AI generation
|
||||
logger.info(f"Cache miss - generating new content for keywords: {req.researchKeywords}")
|
||||
|
||||
# Build schema expected from the model
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"wordCount": {"type": "number"},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}, "url": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Compose prompt
|
||||
def section_block(s):
|
||||
return {
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"outline": {
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
"references": [
|
||||
{"title": r.title, "url": r.url} for r in getattr(s, "references", [])
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
payload = {
|
||||
"title": req.title,
|
||||
"globalTargetWords": req.globalTargetWords or 1000,
|
||||
"persona": req.persona.dict() if req.persona else None,
|
||||
"tone": req.tone,
|
||||
"audience": req.audience,
|
||||
"sections": [section_block(s) for s in req.sections],
|
||||
}
|
||||
|
||||
# Build persona-aware system prompt
|
||||
persona_context = ""
|
||||
if req.persona:
|
||||
persona_context = f"""
|
||||
PERSONA GUIDELINES:
|
||||
- Industry: {req.persona.industry or 'General'}
|
||||
- Tone: {req.persona.tone or 'Professional'}
|
||||
- Audience: {req.persona.audience or 'General readers'}
|
||||
- Persona ID: {req.persona.persona_id or 'Default'}
|
||||
|
||||
Write content that reflects this persona's expertise and communication style.
|
||||
Use industry-specific terminology and examples where appropriate.
|
||||
Maintain consistent voice and authority throughout all sections.
|
||||
"""
|
||||
|
||||
system = (
|
||||
"You are a professional blog writer with deep expertise in your field. "
|
||||
"Generate high-quality, persona-driven content for each section based on the provided outline. "
|
||||
"Write engaging, informative content that follows the section's key points and target word count. "
|
||||
"Ensure the content flows naturally and maintains consistent voice and authority. "
|
||||
"Format content with proper paragraph breaks using double line breaks (\\n\\n) between paragraphs. "
|
||||
"Structure content with clear paragraphs - aim for 2-4 sentences per paragraph. "
|
||||
f"{persona_context}"
|
||||
"Return ONLY valid JSON with no markdown formatting or explanations."
|
||||
)
|
||||
|
||||
# Build persona-specific content instructions
|
||||
persona_instructions = ""
|
||||
if req.persona:
|
||||
industry = req.persona.industry or 'General'
|
||||
tone = req.persona.tone or 'Professional'
|
||||
audience = req.persona.audience or 'General readers'
|
||||
|
||||
persona_instructions = f"""
|
||||
PERSONA-DRIVEN CONTENT REQUIREMENTS:
|
||||
- Write as an expert in {industry} industry
|
||||
- Use {tone} tone appropriate for {audience}
|
||||
- Include industry-specific examples and terminology
|
||||
- Demonstrate authority and expertise in the field
|
||||
- Use language that resonates with {audience}
|
||||
- Maintain consistent voice that reflects this persona's expertise
|
||||
"""
|
||||
|
||||
prompt = (
|
||||
f"Write blog content for the following sections. Each section should be {req.globalTargetWords or 1000} words total, distributed across all sections.\n\n"
|
||||
f"Blog Title: {req.title}\n\n"
|
||||
"For each section, write engaging content that:\n"
|
||||
"- Follows the key points provided\n"
|
||||
"- Uses the suggested keywords naturally\n"
|
||||
"- Meets the target word count\n"
|
||||
"- Maintains professional tone\n"
|
||||
"- References the provided sources when relevant\n"
|
||||
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
|
||||
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
|
||||
"- Starts with an engaging opening paragraph\n"
|
||||
"- Ends with a strong concluding paragraph\n"
|
||||
f"{persona_instructions}\n"
|
||||
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
|
||||
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
|
||||
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
ai_resp = gemini_structured_json_response(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
system_prompt=system,
|
||||
)
|
||||
|
||||
# Check for errors in AI response
|
||||
if not ai_resp or ai_resp.get("error"):
|
||||
error_msg = ai_resp.get("error", "Empty generation result from model") if ai_resp else "No response from model"
|
||||
logger.error(f"AI generation failed: {error_msg}")
|
||||
raise Exception(f"AI generation failed: {error_msg}")
|
||||
|
||||
# Normalize output
|
||||
title = ai_resp.get("title") or req.title
|
||||
out_sections = []
|
||||
for s in ai_resp.get("sections", []) or []:
|
||||
out_sections.append(
|
||||
MediumGeneratedSection(
|
||||
id=str(s.get("id")),
|
||||
heading=s.get("heading") or "",
|
||||
content=s.get("content") or "",
|
||||
wordCount=int(s.get("wordCount") or 0),
|
||||
sources=[
|
||||
# map to ResearchSource shape if possible; keep minimal
|
||||
ResearchSource(title=src.get("title", ""), url=src.get("url", ""))
|
||||
for src in (s.get("sources") or [])
|
||||
] or None,
|
||||
)
|
||||
)
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
result = MediumBlogGenerateResult(
|
||||
success=True,
|
||||
title=title,
|
||||
sections=out_sections,
|
||||
model="gemini-2.5-flash",
|
||||
generation_time_ms=duration_ms,
|
||||
safety_flags=None,
|
||||
)
|
||||
|
||||
# Cache the result for future use
|
||||
try:
|
||||
self.cache.cache_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone or "professional",
|
||||
audience=req.audience or "general",
|
||||
result=result.dict()
|
||||
)
|
||||
logger.info(f"Cached content result for keywords: {req.researchKeywords}")
|
||||
except Exception as cache_error:
|
||||
logger.warning(f"Failed to cache content result: {cache_error}")
|
||||
# Don't fail the entire operation if caching fails
|
||||
|
||||
return result
|
||||
@@ -5,6 +5,8 @@ Coordinates research, outline generation, content creation, and optimization.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
import time
|
||||
import uuid
|
||||
from loguru import logger
|
||||
|
||||
from models.blog_models import (
|
||||
@@ -30,6 +32,8 @@ from models.blog_models import (
|
||||
from ..research import ResearchService
|
||||
from ..outline import OutlineService
|
||||
from ..content.enhanced_content_generator import EnhancedContentGenerator
|
||||
from ..content.medium_blog_generator import MediumBlogGenerator
|
||||
from ..content.blog_rewriter import BlogRewriter
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.cache.persistent_content_cache import persistent_content_cache
|
||||
from models.blog_models import (
|
||||
@@ -38,6 +42,47 @@ from models.blog_models import (
|
||||
MediumGeneratedSection,
|
||||
)
|
||||
|
||||
# Import task manager - we'll create a simple one for this service
|
||||
class SimpleTaskManager:
|
||||
"""Simple task manager for BlogWriterService."""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
|
||||
def start_task(self, task_id: str, func, **kwargs):
|
||||
"""Start a task with the given function and arguments."""
|
||||
import asyncio
|
||||
self.tasks[task_id] = {
|
||||
"status": "running",
|
||||
"progress": "Starting...",
|
||||
"result": None,
|
||||
"error": None
|
||||
}
|
||||
# Start the task in the background
|
||||
asyncio.create_task(self._run_task(task_id, func, **kwargs))
|
||||
|
||||
async def _run_task(self, task_id: str, func, **kwargs):
|
||||
"""Run the task function."""
|
||||
try:
|
||||
await func(task_id, **kwargs)
|
||||
except Exception as e:
|
||||
self.tasks[task_id]["status"] = "failed"
|
||||
self.tasks[task_id]["error"] = str(e)
|
||||
logger.error(f"Task {task_id} failed: {e}")
|
||||
|
||||
def update_task_status(self, task_id: str, status: str, progress: str = None, result=None):
|
||||
"""Update task status."""
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id]["status"] = status
|
||||
if progress:
|
||||
self.tasks[task_id]["progress"] = progress
|
||||
if result:
|
||||
self.tasks[task_id]["result"] = result
|
||||
|
||||
def get_task_status(self, task_id: str):
|
||||
"""Get task status."""
|
||||
return self.tasks.get(task_id, {"status": "not_found"})
|
||||
|
||||
|
||||
class BlogWriterService:
|
||||
"""Main service orchestrator for AI Blog Writer functionality."""
|
||||
@@ -46,6 +91,9 @@ class BlogWriterService:
|
||||
self.research_service = ResearchService()
|
||||
self.outline_service = OutlineService()
|
||||
self.content_generator = EnhancedContentGenerator()
|
||||
self.task_manager = SimpleTaskManager()
|
||||
self.medium_blog_generator = MediumBlogGenerator()
|
||||
self.blog_rewriter = BlogRewriter(self.task_manager)
|
||||
|
||||
# Research Methods
|
||||
async def research(self, request: BlogResearchRequest) -> BlogResearchResponse:
|
||||
@@ -157,98 +205,67 @@ class BlogWriterService:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def seo_analyze(self, request: BlogSEOAnalyzeRequest) -> BlogSEOAnalyzeResponse:
|
||||
"""Analyze content for SEO optimization."""
|
||||
from services.seo_tools.on_page_seo_service import OnPageSEOService
|
||||
from services.seo_tools.image_alt_service import ImageAltService
|
||||
from services.seo_tools.content_strategy_service import ContentStrategyService
|
||||
|
||||
content = request.content or ""
|
||||
target_keywords = request.keywords or []
|
||||
|
||||
# On-page analysis (treat content as a virtual URL/document for now)
|
||||
on_page = OnPageSEOService()
|
||||
on_page_result = await on_page.analyze_on_page_seo(url="about:blank", target_keywords=target_keywords)
|
||||
|
||||
# Image alt coverage (placeholder: no images in raw content yet)
|
||||
"""Analyze content for SEO optimization using comprehensive blog-specific analyzer."""
|
||||
try:
|
||||
image_alt_service = ImageAltService()
|
||||
image_alt_status = {"total_images": 0, "missing_alt": 0}
|
||||
except Exception:
|
||||
image_alt_status = {"total_images": 0, "missing_alt": 0}
|
||||
from services.blog_writer.seo.blog_content_seo_analyzer import BlogContentSEOAnalyzer
|
||||
|
||||
# Strategy hints (keywords/topics)
|
||||
try:
|
||||
strategy = ContentStrategyService()
|
||||
strategy_hints = await strategy.analyze_content_topics(content=content)
|
||||
except Exception:
|
||||
strategy_hints = {"topics": [], "gaps": []}
|
||||
content = request.content or ""
|
||||
target_keywords = request.keywords or []
|
||||
|
||||
# Lightweight markdown parsing for headings/links/keywords
|
||||
import re
|
||||
content_text = content or ""
|
||||
words = re.findall(r"[A-Za-z0-9']+", content_text)
|
||||
total_words = max(len(words), 1)
|
||||
heading_lines = content_text.splitlines()
|
||||
h1 = sum(1 for ln in heading_lines if ln.startswith('# '))
|
||||
h2 = sum(1 for ln in heading_lines if ln.startswith('## '))
|
||||
h3 = sum(1 for ln in heading_lines if ln.startswith('### '))
|
||||
md_links = re.findall(r"\[([^\]]+)\]\(([^)]+)\)", content_text)
|
||||
external_links = [u for (_t, u) in md_links if u.startswith('http')]
|
||||
|
||||
# Keyword density
|
||||
density_map: Dict[str, Any] = {"target_keywords": target_keywords}
|
||||
for kw in target_keywords:
|
||||
try:
|
||||
occurrences = len(re.findall(re.escape(kw), content_text, flags=re.IGNORECASE))
|
||||
except re.error:
|
||||
occurrences = 0
|
||||
density_map[kw] = {
|
||||
"occurrences": occurrences,
|
||||
"density": round(occurrences / total_words, 4)
|
||||
}
|
||||
|
||||
# Build unified response
|
||||
recommendations: List[str] = []
|
||||
if isinstance(on_page_result.get("recommendations"), list):
|
||||
recommendations.extend(on_page_result["recommendations"])
|
||||
if strategy_hints.get("gaps"):
|
||||
recommendations.append("Cover missing topics: " + ", ".join(strategy_hints["gaps"]))
|
||||
if not external_links:
|
||||
recommendations.append("Add at least one credible external link to authoritative sources.")
|
||||
if h2 < 2:
|
||||
recommendations.append("Increase number of H2 sections for better structure.")
|
||||
|
||||
# Internal link suggestions: generate anchors for H2s and propose cross-links
|
||||
def to_anchor(h: str) -> str:
|
||||
import re
|
||||
a = re.sub(r"[^a-z0-9\s-]", "", h.lower())
|
||||
a = re.sub(r"\s+", "-", a).strip('-')
|
||||
return a
|
||||
h2_headings = [ln[3:].strip() for ln in heading_lines if ln.startswith('## ')]
|
||||
anchors = [to_anchor(h) for h in h2_headings]
|
||||
internal_link_suggestions = []
|
||||
for i in range(len(anchors)-1):
|
||||
internal_link_suggestions.append({
|
||||
"from": h2_headings[i],
|
||||
"to": h2_headings[i+1],
|
||||
"anchor": f"#{anchors[i+1]}",
|
||||
"suggestion": f"Add internal link from '{h2_headings[i]}' to '{h2_headings[i+1]}'"
|
||||
})
|
||||
|
||||
return BlogSEOAnalyzeResponse(
|
||||
success=True,
|
||||
seo_score=float(on_page_result.get("overall_score", 75)),
|
||||
density=density_map,
|
||||
structure={
|
||||
**on_page_result.get("heading_structure", {}),
|
||||
"markdown_headings": {"h1": h1, "h2": h2, "h3": h3},
|
||||
"links": {"total": len(md_links), "external": len(external_links)}
|
||||
},
|
||||
readability=on_page_result.get("content_analysis", {}),
|
||||
link_suggestions=([{"suggestion": "Add external citation links for key claims."}] if not external_links else []) + internal_link_suggestions,
|
||||
image_alt_status=image_alt_status,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
# Use research data from request if available, otherwise create fallback
|
||||
if request.research_data:
|
||||
research_data = request.research_data
|
||||
logger.info(f"Using research data from request: {research_data.get('keyword_analysis', {})}")
|
||||
else:
|
||||
# Fallback for backward compatibility
|
||||
research_data = {
|
||||
"keyword_analysis": {
|
||||
"primary": target_keywords,
|
||||
"long_tail": [],
|
||||
"semantic": [],
|
||||
"all_keywords": target_keywords,
|
||||
"search_intent": "informational"
|
||||
}
|
||||
}
|
||||
logger.warning("No research data provided, using fallback keywords")
|
||||
|
||||
# Use our comprehensive SEO analyzer
|
||||
analyzer = BlogContentSEOAnalyzer()
|
||||
analysis_results = await analyzer.analyze_blog_content(content, research_data)
|
||||
|
||||
# Convert results to response format
|
||||
recommendations = analysis_results.get('actionable_recommendations', [])
|
||||
# Convert recommendation objects to strings
|
||||
recommendation_strings = []
|
||||
for rec in recommendations:
|
||||
if isinstance(rec, dict):
|
||||
recommendation_strings.append(f"[{rec.get('category', 'General')}] {rec.get('recommendation', '')}")
|
||||
else:
|
||||
recommendation_strings.append(str(rec))
|
||||
|
||||
return BlogSEOAnalyzeResponse(
|
||||
success=True,
|
||||
seo_score=float(analysis_results.get('overall_score', 0)),
|
||||
density=analysis_results.get('visualization_data', {}).get('keyword_analysis', {}).get('densities', {}),
|
||||
structure=analysis_results.get('detailed_analysis', {}).get('content_structure', {}),
|
||||
readability=analysis_results.get('detailed_analysis', {}).get('readability_analysis', {}),
|
||||
link_suggestions=[],
|
||||
image_alt_status={"total_images": 0, "missing_alt": 0},
|
||||
recommendations=recommendation_strings
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SEO analysis failed: {e}")
|
||||
return BlogSEOAnalyzeResponse(
|
||||
success=False,
|
||||
seo_score=0.0,
|
||||
density={},
|
||||
structure={},
|
||||
readability={},
|
||||
link_suggestions=[],
|
||||
image_alt_status={"total_images": 0, "missing_alt": 0},
|
||||
recommendations=[f"SEO analysis failed: {str(e)}"]
|
||||
)
|
||||
|
||||
async def seo_metadata(self, request: BlogSEOMetadataRequest) -> BlogSEOMetadataResponse:
|
||||
"""Generate SEO metadata for content."""
|
||||
@@ -269,177 +286,171 @@ class BlogWriterService:
|
||||
|
||||
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str) -> MediumBlogGenerateResult:
|
||||
"""Use Gemini structured JSON to generate a medium-length blog in one call."""
|
||||
import time
|
||||
start = time.time()
|
||||
return await self.medium_blog_generator.generate_medium_blog_with_progress(req, task_id)
|
||||
|
||||
# Prepare sections data for cache key generation
|
||||
sections_for_cache = []
|
||||
for s in req.sections:
|
||||
sections_for_cache.append({
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
})
|
||||
|
||||
# Check cache first
|
||||
cached_result = persistent_content_cache.get_cached_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone,
|
||||
audience=req.audience
|
||||
)
|
||||
|
||||
if cached_result:
|
||||
logger.info(f"Using cached content for keywords: {req.researchKeywords} (saved expensive generation)")
|
||||
# Add cache hit marker to distinguish from fresh generation
|
||||
cached_result['generation_time_ms'] = 0 # Mark as cache hit
|
||||
cached_result['cache_hit'] = True
|
||||
return MediumBlogGenerateResult(**cached_result)
|
||||
|
||||
# Cache miss - proceed with AI generation
|
||||
logger.info(f"Cache miss - generating new content for keywords: {req.researchKeywords}")
|
||||
|
||||
# Build schema expected from the model
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
async def analyze_flow_basic(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze flow metrics for entire blog using single AI call (cost-effective)."""
|
||||
try:
|
||||
# Extract blog content from request
|
||||
sections = request.get("sections", [])
|
||||
title = request.get("title", "Untitled Blog")
|
||||
|
||||
if not sections:
|
||||
return {"error": "No sections provided for analysis"}
|
||||
|
||||
# Combine all content for analysis
|
||||
full_content = f"Title: {title}\n\n"
|
||||
for section in sections:
|
||||
full_content += f"Section: {section.get('heading', 'Untitled')}\n"
|
||||
full_content += f"Content: {section.get('content', '')}\n\n"
|
||||
|
||||
# Build analysis prompt
|
||||
system_prompt = """You are an expert content analyst specializing in narrative flow, consistency, and progression analysis.
|
||||
Analyze the provided blog content and provide detailed, actionable feedback for improvement.
|
||||
Focus on how well the content flows from section to section, maintains consistency in tone and style,
|
||||
and progresses logically through the topic."""
|
||||
|
||||
analysis_prompt = f"""
|
||||
Analyze the following blog content for narrative flow, consistency, and progression:
|
||||
|
||||
{full_content}
|
||||
|
||||
Evaluate each section and provide overall analysis with specific scores and actionable suggestions.
|
||||
Consider:
|
||||
- How well each section flows into the next
|
||||
- Consistency in tone, style, and voice throughout
|
||||
- Logical progression of ideas and arguments
|
||||
- Transition quality between sections
|
||||
- Overall coherence and readability
|
||||
|
||||
IMPORTANT: For each section in the response, use the exact section ID provided in the input.
|
||||
The section IDs in your response must match the section IDs from the input exactly.
|
||||
|
||||
Provide detailed analysis with specific, actionable suggestions for improvement.
|
||||
"""
|
||||
|
||||
# Use Gemini for structured analysis
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"overall_flow_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"overall_consistency_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"overall_progression_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"overall_coherence_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"section_id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"flow_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"consistency_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"progression_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"coherence_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"transition_quality": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"suggestions": {"type": "array", "items": {"type": "string"}},
|
||||
"strengths": {"type": "array", "items": {"type": "string"}},
|
||||
"improvement_areas": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["section_id", "heading", "flow_score", "consistency_score", "progression_score", "coherence_score", "transition_quality", "suggestions"]
|
||||
}
|
||||
},
|
||||
"overall_suggestions": {"type": "array", "items": {"type": "string"}},
|
||||
"overall_strengths": {"type": "array", "items": {"type": "string"}},
|
||||
"overall_improvement_areas": {"type": "array", "items": {"type": "string"}},
|
||||
"transition_analysis": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"wordCount": {"type": "number"},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}, "url": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Compose prompt
|
||||
def section_block(s):
|
||||
return {
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"outline": {
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
"references": [
|
||||
{"title": r.title, "url": r.url} for r in getattr(s, "references", [])
|
||||
],
|
||||
"overall_transition_quality": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"transition_suggestions": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["overall_flow_score", "overall_consistency_score", "overall_progression_score", "overall_coherence_score", "sections", "overall_suggestions"]
|
||||
}
|
||||
|
||||
payload = {
|
||||
"title": req.title,
|
||||
"globalTargetWords": req.globalTargetWords or 1000,
|
||||
"persona": req.persona.dict() if req.persona else None,
|
||||
"tone": req.tone,
|
||||
"audience": req.audience,
|
||||
"sections": [section_block(s) for s in req.sections],
|
||||
}
|
||||
|
||||
system = (
|
||||
"You are a professional blog writer. Generate high-quality content for each section based on the provided outline. "
|
||||
"Write engaging, informative content that follows the section's key points and target word count. "
|
||||
"Use a professional tone and ensure the content flows naturally. "
|
||||
"Format content with proper paragraph breaks using double line breaks (\\n\\n) between paragraphs. "
|
||||
"Structure content with clear paragraphs - aim for 2-4 sentences per paragraph. "
|
||||
"Return ONLY valid JSON with no markdown formatting or explanations."
|
||||
)
|
||||
|
||||
import json
|
||||
prompt = (
|
||||
f"Write blog content for the following sections. Each section should be {req.globalTargetWords or 1000} words total, distributed across all sections.\n\n"
|
||||
f"Blog Title: {req.title}\n\n"
|
||||
"For each section, write engaging content that:\n"
|
||||
"- Follows the key points provided\n"
|
||||
"- Uses the suggested keywords naturally\n"
|
||||
"- Meets the target word count\n"
|
||||
"- Maintains professional tone\n"
|
||||
"- References the provided sources when relevant\n"
|
||||
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
|
||||
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
|
||||
"- Starts with an engaging opening paragraph\n"
|
||||
"- Ends with a strong concluding paragraph\n\n"
|
||||
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
|
||||
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
|
||||
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
ai_resp = gemini_structured_json_response(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
system_prompt=system,
|
||||
)
|
||||
|
||||
# Check for errors in AI response
|
||||
if not ai_resp or ai_resp.get("error"):
|
||||
error_msg = ai_resp.get("error", "Empty generation result from model") if ai_resp else "No response from model"
|
||||
logger.error(f"AI generation failed: {error_msg}")
|
||||
raise Exception(f"AI generation failed: {error_msg}")
|
||||
|
||||
# Normalize output
|
||||
title = ai_resp.get("title") or req.title
|
||||
out_sections = []
|
||||
for s in ai_resp.get("sections", []) or []:
|
||||
out_sections.append(
|
||||
MediumGeneratedSection(
|
||||
id=str(s.get("id")),
|
||||
heading=s.get("heading") or "",
|
||||
content=s.get("content") or "",
|
||||
wordCount=int(s.get("wordCount") or 0),
|
||||
sources=[
|
||||
# map to ResearchSource shape if possible; keep minimal
|
||||
ResearchSource(title=src.get("title", ""), url=src.get("url", ""))
|
||||
for src in (s.get("sources") or [])
|
||||
] or None,
|
||||
)
|
||||
|
||||
result = gemini_structured_json_response(
|
||||
prompt=analysis_prompt,
|
||||
schema=schema,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
if result and not result.get("error"):
|
||||
logger.info("Basic flow analysis completed successfully")
|
||||
return {"success": True, "analysis": result, "mode": "basic"}
|
||||
else:
|
||||
error_msg = result.get("error", "Analysis failed") if result else "No response from AI"
|
||||
logger.error(f"Basic flow analysis failed: {error_msg}")
|
||||
return {"error": error_msg}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Basic flow analysis error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
result = MediumBlogGenerateResult(
|
||||
success=True,
|
||||
title=title,
|
||||
sections=out_sections,
|
||||
model="gemini-2.5-flash",
|
||||
generation_time_ms=duration_ms,
|
||||
safety_flags=None,
|
||||
)
|
||||
|
||||
# Cache the result for future use
|
||||
async def analyze_flow_advanced(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze flow metrics for each section individually (detailed but expensive)."""
|
||||
try:
|
||||
persistent_content_cache.cache_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone or "professional",
|
||||
audience=req.audience or "general",
|
||||
result=result.dict()
|
||||
)
|
||||
logger.info(f"Cached content result for keywords: {req.researchKeywords}")
|
||||
except Exception as cache_error:
|
||||
logger.warning(f"Failed to cache content result: {cache_error}")
|
||||
# Don't fail the entire operation if caching fails
|
||||
|
||||
return result
|
||||
# Use the existing enhanced content generator for detailed analysis
|
||||
sections = request.get("sections", [])
|
||||
title = request.get("title", "Untitled Blog")
|
||||
|
||||
if not sections:
|
||||
return {"error": "No sections provided for analysis"}
|
||||
|
||||
results = []
|
||||
for section in sections:
|
||||
# Use the existing flow analyzer for each section
|
||||
section_content = section.get("content", "")
|
||||
section_heading = section.get("heading", "Untitled")
|
||||
|
||||
# Get previous section context for better analysis
|
||||
prev_section_content = ""
|
||||
if len(results) > 0:
|
||||
prev_section_content = results[-1].get("content", "")
|
||||
|
||||
# Use the existing flow analyzer
|
||||
flow_metrics = self.content_generator.flow.assess_flow(
|
||||
prev_section_content,
|
||||
section_content,
|
||||
use_llm=True
|
||||
)
|
||||
|
||||
results.append({
|
||||
"section_id": section.get("id", "unknown"),
|
||||
"heading": section_heading,
|
||||
"flow_score": flow_metrics.get("flow", 0.0),
|
||||
"consistency_score": flow_metrics.get("consistency", 0.0),
|
||||
"progression_score": flow_metrics.get("progression", 0.0),
|
||||
"detailed_analysis": flow_metrics.get("analysis", ""),
|
||||
"suggestions": flow_metrics.get("suggestions", [])
|
||||
})
|
||||
|
||||
# Calculate overall scores
|
||||
overall_flow = sum(r["flow_score"] for r in results) / len(results) if results else 0.0
|
||||
overall_consistency = sum(r["consistency_score"] for r in results) / len(results) if results else 0.0
|
||||
overall_progression = sum(r["progression_score"] for r in results) / len(results) if results else 0.0
|
||||
|
||||
logger.info("Advanced flow analysis completed successfully")
|
||||
return {
|
||||
"success": True,
|
||||
"analysis": {
|
||||
"overall_flow_score": overall_flow,
|
||||
"overall_consistency_score": overall_consistency,
|
||||
"overall_progression_score": overall_progression,
|
||||
"sections": results
|
||||
},
|
||||
"mode": "advanced"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Advanced flow analysis error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def start_blog_rewrite(self, request: Dict[str, Any]) -> str:
|
||||
"""Start blog rewrite task with user feedback."""
|
||||
return self.blog_rewriter.start_blog_rewrite(request)
|
||||
|
||||
872
backend/services/blog_writer/seo/blog_content_seo_analyzer.py
Normal file
872
backend/services/blog_writer/seo/blog_content_seo_analyzer.py
Normal file
@@ -0,0 +1,872 @@
|
||||
"""
|
||||
Blog Content SEO Analyzer
|
||||
|
||||
Specialized SEO analyzer for blog content with parallel processing.
|
||||
Leverages existing non-AI SEO tools and uses single AI prompt for structured analysis.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import textstat
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
from services.seo_analyzer import (
|
||||
ContentAnalyzer, KeywordAnalyzer,
|
||||
URLStructureAnalyzer, AIInsightGenerator
|
||||
)
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
|
||||
|
||||
class BlogContentSEOAnalyzer:
|
||||
"""Specialized SEO analyzer for blog content with parallel processing"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the blog content SEO analyzer"""
|
||||
self.content_analyzer = ContentAnalyzer()
|
||||
self.keyword_analyzer = KeywordAnalyzer()
|
||||
self.url_analyzer = URLStructureAnalyzer()
|
||||
self.ai_insights = AIInsightGenerator()
|
||||
self.gemini_provider = gemini_structured_json_response
|
||||
|
||||
logger.info("BlogContentSEOAnalyzer initialized")
|
||||
|
||||
async def analyze_blog_content(self, blog_content: str, research_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Main analysis method with parallel processing
|
||||
|
||||
Args:
|
||||
blog_content: The blog content to analyze
|
||||
research_data: Research data containing keywords and other insights
|
||||
|
||||
Returns:
|
||||
Comprehensive SEO analysis results
|
||||
"""
|
||||
try:
|
||||
logger.info("Starting blog content SEO analysis")
|
||||
|
||||
# Extract keywords from research data
|
||||
keywords_data = self._extract_keywords_from_research(research_data)
|
||||
logger.info(f"Extracted keywords: {keywords_data}")
|
||||
|
||||
# Phase 1: Run non-AI analyzers in parallel
|
||||
logger.info("Running non-AI analyzers in parallel")
|
||||
non_ai_results = await self._run_non_ai_analyzers(blog_content, keywords_data)
|
||||
|
||||
# Phase 2: Single AI analysis for structured insights
|
||||
logger.info("Running AI analysis")
|
||||
ai_insights = await self._run_ai_analysis(blog_content, keywords_data, non_ai_results)
|
||||
|
||||
# Phase 3: Compile and format results
|
||||
logger.info("Compiling results")
|
||||
results = self._compile_blog_seo_results(non_ai_results, ai_insights, keywords_data)
|
||||
|
||||
logger.info(f"SEO analysis completed. Overall score: {results.get('overall_score', 0)}")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Blog SEO analysis failed: {e}")
|
||||
# Fail fast - don't return fallback data
|
||||
raise e
|
||||
|
||||
def _extract_keywords_from_research(self, research_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract keywords from research data"""
|
||||
try:
|
||||
logger.info(f"Extracting keywords from research data: {research_data}")
|
||||
|
||||
# Extract keywords from research data structure
|
||||
keyword_analysis = research_data.get('keyword_analysis', {})
|
||||
logger.info(f"Found keyword_analysis: {keyword_analysis}")
|
||||
|
||||
# Handle different possible structures
|
||||
primary_keywords = []
|
||||
long_tail_keywords = []
|
||||
semantic_keywords = []
|
||||
all_keywords = []
|
||||
|
||||
# Try to extract primary keywords from different possible locations
|
||||
if 'primary' in keyword_analysis:
|
||||
primary_keywords = keyword_analysis.get('primary', [])
|
||||
elif 'keywords' in research_data:
|
||||
# Fallback to top-level keywords
|
||||
primary_keywords = research_data.get('keywords', [])
|
||||
|
||||
# Extract other keyword types
|
||||
long_tail_keywords = keyword_analysis.get('long_tail', [])
|
||||
# Handle both 'semantic' and 'semantic_keywords' field names
|
||||
semantic_keywords = keyword_analysis.get('semantic', []) or keyword_analysis.get('semantic_keywords', [])
|
||||
all_keywords = keyword_analysis.get('all_keywords', primary_keywords)
|
||||
|
||||
result = {
|
||||
'primary': primary_keywords,
|
||||
'long_tail': long_tail_keywords,
|
||||
'semantic': semantic_keywords,
|
||||
'all_keywords': all_keywords,
|
||||
'search_intent': keyword_analysis.get('search_intent', 'informational')
|
||||
}
|
||||
|
||||
logger.info(f"Extracted keywords: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract keywords from research data: {e}")
|
||||
logger.error(f"Research data structure: {research_data}")
|
||||
# Fail fast - don't return empty keywords
|
||||
raise ValueError(f"Keyword extraction failed: {e}")
|
||||
|
||||
async def _run_non_ai_analyzers(self, blog_content: str, keywords_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run all non-AI analyzers in parallel for maximum performance"""
|
||||
|
||||
logger.info(f"Starting non-AI analyzers with content length: {len(blog_content)} chars")
|
||||
logger.info(f"Keywords data: {keywords_data}")
|
||||
|
||||
# Parallel execution of fast analyzers
|
||||
tasks = [
|
||||
self._analyze_content_structure(blog_content),
|
||||
self._analyze_keyword_usage(blog_content, keywords_data),
|
||||
self._analyze_readability(blog_content),
|
||||
self._analyze_content_quality(blog_content),
|
||||
self._analyze_heading_structure(blog_content)
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check for exceptions and fail fast
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
task_names = ['content_structure', 'keyword_analysis', 'readability_analysis', 'content_quality', 'heading_structure']
|
||||
logger.error(f"Task {task_names[i]} failed: {result}")
|
||||
raise result
|
||||
|
||||
# Log successful results
|
||||
task_names = ['content_structure', 'keyword_analysis', 'readability_analysis', 'content_quality', 'heading_structure']
|
||||
for i, (name, result) in enumerate(zip(task_names, results)):
|
||||
logger.info(f"✅ {name} completed: {type(result).__name__} with {len(result) if isinstance(result, dict) else 'N/A'} fields")
|
||||
|
||||
return {
|
||||
'content_structure': results[0],
|
||||
'keyword_analysis': results[1],
|
||||
'readability_analysis': results[2],
|
||||
'content_quality': results[3],
|
||||
'heading_structure': results[4]
|
||||
}
|
||||
|
||||
async def _analyze_content_structure(self, content: str) -> Dict[str, Any]:
|
||||
"""Analyze blog content structure"""
|
||||
try:
|
||||
# Parse markdown content
|
||||
lines = content.split('\n')
|
||||
|
||||
# Count sections, paragraphs, sentences
|
||||
sections = len([line for line in lines if line.startswith('##')])
|
||||
paragraphs = len([line for line in lines if line.strip() and not line.startswith('#')])
|
||||
sentences = len(re.findall(r'[.!?]+', content))
|
||||
|
||||
# Blog-specific structure analysis
|
||||
has_introduction = any('introduction' in line.lower() or 'overview' in line.lower()
|
||||
for line in lines[:10])
|
||||
has_conclusion = any('conclusion' in line.lower() or 'summary' in line.lower()
|
||||
for line in lines[-10:])
|
||||
has_cta = any('call to action' in line.lower() or 'learn more' in line.lower()
|
||||
for line in lines)
|
||||
|
||||
structure_score = self._calculate_structure_score(sections, paragraphs, has_introduction, has_conclusion)
|
||||
|
||||
return {
|
||||
'total_sections': sections,
|
||||
'total_paragraphs': paragraphs,
|
||||
'total_sentences': sentences,
|
||||
'has_introduction': has_introduction,
|
||||
'has_conclusion': has_conclusion,
|
||||
'has_call_to_action': has_cta,
|
||||
'structure_score': structure_score,
|
||||
'recommendations': self._get_structure_recommendations(sections, has_introduction, has_conclusion)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Content structure analysis failed: {e}")
|
||||
raise e
|
||||
|
||||
async def _analyze_keyword_usage(self, content: str, keywords_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze keyword usage and optimization"""
|
||||
try:
|
||||
# Extract keywords from research data
|
||||
primary_keywords = keywords_data.get('primary', [])
|
||||
long_tail_keywords = keywords_data.get('long_tail', [])
|
||||
semantic_keywords = keywords_data.get('semantic', [])
|
||||
|
||||
# Use existing KeywordAnalyzer
|
||||
keyword_result = self.keyword_analyzer.analyze(content, primary_keywords)
|
||||
|
||||
# Blog-specific keyword analysis
|
||||
keyword_analysis = {
|
||||
'primary_keywords': primary_keywords,
|
||||
'long_tail_keywords': long_tail_keywords,
|
||||
'semantic_keywords': semantic_keywords,
|
||||
'keyword_density': {},
|
||||
'keyword_distribution': {},
|
||||
'missing_keywords': [],
|
||||
'over_optimization': [],
|
||||
'recommendations': []
|
||||
}
|
||||
|
||||
# Analyze each keyword type
|
||||
for keyword in primary_keywords:
|
||||
density = self._calculate_keyword_density(content, keyword)
|
||||
keyword_analysis['keyword_density'][keyword] = density
|
||||
|
||||
# Check if keyword appears in headings
|
||||
in_headings = self._keyword_in_headings(content, keyword)
|
||||
keyword_analysis['keyword_distribution'][keyword] = {
|
||||
'density': density,
|
||||
'in_headings': in_headings,
|
||||
'first_occurrence': content.lower().find(keyword.lower())
|
||||
}
|
||||
|
||||
# Check for missing important keywords
|
||||
for keyword in primary_keywords:
|
||||
if keyword.lower() not in content.lower():
|
||||
keyword_analysis['missing_keywords'].append(keyword)
|
||||
|
||||
# Check for over-optimization
|
||||
for keyword, density in keyword_analysis['keyword_density'].items():
|
||||
if density > 3.0: # Over 3% density
|
||||
keyword_analysis['over_optimization'].append(keyword)
|
||||
|
||||
return keyword_analysis
|
||||
except Exception as e:
|
||||
logger.error(f"Keyword analysis failed: {e}")
|
||||
raise e
|
||||
|
||||
async def _analyze_readability(self, content: str) -> Dict[str, Any]:
|
||||
"""Analyze content readability using textstat integration"""
|
||||
try:
|
||||
# Calculate readability metrics
|
||||
readability_metrics = {
|
||||
'flesch_reading_ease': textstat.flesch_reading_ease(content),
|
||||
'flesch_kincaid_grade': textstat.flesch_kincaid_grade(content),
|
||||
'gunning_fog': textstat.gunning_fog(content),
|
||||
'smog_index': textstat.smog_index(content),
|
||||
'automated_readability': textstat.automated_readability_index(content),
|
||||
'coleman_liau': textstat.coleman_liau_index(content)
|
||||
}
|
||||
|
||||
# Blog-specific readability analysis
|
||||
avg_sentence_length = self._calculate_avg_sentence_length(content)
|
||||
avg_paragraph_length = self._calculate_avg_paragraph_length(content)
|
||||
|
||||
readability_score = self._calculate_readability_score(readability_metrics)
|
||||
|
||||
return {
|
||||
'metrics': readability_metrics,
|
||||
'avg_sentence_length': avg_sentence_length,
|
||||
'avg_paragraph_length': avg_paragraph_length,
|
||||
'readability_score': readability_score,
|
||||
'target_audience': self._determine_target_audience(readability_metrics),
|
||||
'recommendations': self._get_readability_recommendations(readability_metrics, avg_sentence_length)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Readability analysis failed: {e}")
|
||||
raise e
|
||||
|
||||
async def _analyze_content_quality(self, content: str) -> Dict[str, Any]:
|
||||
"""Analyze overall content quality"""
|
||||
try:
|
||||
# Word count analysis
|
||||
words = content.split()
|
||||
word_count = len(words)
|
||||
|
||||
# Content depth analysis
|
||||
unique_words = len(set(word.lower() for word in words))
|
||||
vocabulary_diversity = unique_words / word_count if word_count > 0 else 0
|
||||
|
||||
# Content flow analysis
|
||||
transition_words = ['however', 'therefore', 'furthermore', 'moreover', 'additionally', 'consequently']
|
||||
transition_count = sum(content.lower().count(word) for word in transition_words)
|
||||
|
||||
content_depth_score = self._calculate_content_depth_score(word_count, vocabulary_diversity)
|
||||
flow_score = self._calculate_flow_score(transition_count, word_count)
|
||||
|
||||
return {
|
||||
'word_count': word_count,
|
||||
'unique_words': unique_words,
|
||||
'vocabulary_diversity': vocabulary_diversity,
|
||||
'transition_words_used': transition_count,
|
||||
'content_depth_score': content_depth_score,
|
||||
'flow_score': flow_score,
|
||||
'recommendations': self._get_content_quality_recommendations(word_count, vocabulary_diversity, transition_count)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Content quality analysis failed: {e}")
|
||||
raise e
|
||||
|
||||
async def _analyze_heading_structure(self, content: str) -> Dict[str, Any]:
|
||||
"""Analyze heading structure and hierarchy"""
|
||||
try:
|
||||
# Extract headings
|
||||
h1_headings = re.findall(r'^# (.+)$', content, re.MULTILINE)
|
||||
h2_headings = re.findall(r'^## (.+)$', content, re.MULTILINE)
|
||||
h3_headings = re.findall(r'^### (.+)$', content, re.MULTILINE)
|
||||
|
||||
# Analyze heading structure
|
||||
heading_hierarchy_score = self._calculate_heading_hierarchy_score(h1_headings, h2_headings, h3_headings)
|
||||
|
||||
return {
|
||||
'h1_count': len(h1_headings),
|
||||
'h2_count': len(h2_headings),
|
||||
'h3_count': len(h3_headings),
|
||||
'h1_headings': h1_headings,
|
||||
'h2_headings': h2_headings,
|
||||
'h3_headings': h3_headings,
|
||||
'heading_hierarchy_score': heading_hierarchy_score,
|
||||
'recommendations': self._get_heading_recommendations(h1_headings, h2_headings, h3_headings)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Heading structure analysis failed: {e}")
|
||||
raise e
|
||||
|
||||
# Helper methods for calculations and scoring
|
||||
def _calculate_structure_score(self, sections: int, paragraphs: int, has_intro: bool, has_conclusion: bool) -> int:
|
||||
"""Calculate content structure score"""
|
||||
score = 0
|
||||
|
||||
# Section count (optimal: 3-8 sections)
|
||||
if 3 <= sections <= 8:
|
||||
score += 30
|
||||
elif sections < 3:
|
||||
score += 15
|
||||
else:
|
||||
score += 20
|
||||
|
||||
# Paragraph count (optimal: 8-20 paragraphs)
|
||||
if 8 <= paragraphs <= 20:
|
||||
score += 30
|
||||
elif paragraphs < 8:
|
||||
score += 15
|
||||
else:
|
||||
score += 20
|
||||
|
||||
# Introduction and conclusion
|
||||
if has_intro:
|
||||
score += 20
|
||||
if has_conclusion:
|
||||
score += 20
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _calculate_keyword_density(self, content: str, keyword: str) -> float:
|
||||
"""Calculate keyword density percentage"""
|
||||
content_lower = content.lower()
|
||||
keyword_lower = keyword.lower()
|
||||
|
||||
word_count = len(content.split())
|
||||
keyword_count = content_lower.count(keyword_lower)
|
||||
|
||||
return (keyword_count / word_count * 100) if word_count > 0 else 0
|
||||
|
||||
def _keyword_in_headings(self, content: str, keyword: str) -> bool:
|
||||
"""Check if keyword appears in headings"""
|
||||
headings = re.findall(r'^#+ (.+)$', content, re.MULTILINE)
|
||||
return any(keyword.lower() in heading.lower() for heading in headings)
|
||||
|
||||
def _calculate_avg_sentence_length(self, content: str) -> float:
|
||||
"""Calculate average sentence length"""
|
||||
sentences = re.split(r'[.!?]+', content)
|
||||
sentences = [s.strip() for s in sentences if s.strip()]
|
||||
|
||||
if not sentences:
|
||||
return 0
|
||||
|
||||
total_words = sum(len(sentence.split()) for sentence in sentences)
|
||||
return total_words / len(sentences)
|
||||
|
||||
def _calculate_avg_paragraph_length(self, content: str) -> float:
|
||||
"""Calculate average paragraph length"""
|
||||
paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()]
|
||||
|
||||
if not paragraphs:
|
||||
return 0
|
||||
|
||||
total_words = sum(len(paragraph.split()) for paragraph in paragraphs)
|
||||
return total_words / len(paragraphs)
|
||||
|
||||
def _calculate_readability_score(self, metrics: Dict[str, float]) -> int:
|
||||
"""Calculate overall readability score"""
|
||||
# Flesch Reading Ease (0-100, higher is better)
|
||||
flesch_score = metrics.get('flesch_reading_ease', 0)
|
||||
|
||||
# Convert to 0-100 scale
|
||||
if flesch_score >= 80:
|
||||
return 90
|
||||
elif flesch_score >= 60:
|
||||
return 80
|
||||
elif flesch_score >= 40:
|
||||
return 70
|
||||
elif flesch_score >= 20:
|
||||
return 60
|
||||
else:
|
||||
return 50
|
||||
|
||||
def _determine_target_audience(self, metrics: Dict[str, float]) -> str:
|
||||
"""Determine target audience based on readability metrics"""
|
||||
flesch_score = metrics.get('flesch_reading_ease', 0)
|
||||
|
||||
if flesch_score >= 80:
|
||||
return "General audience (8th grade level)"
|
||||
elif flesch_score >= 60:
|
||||
return "High school level"
|
||||
elif flesch_score >= 40:
|
||||
return "College level"
|
||||
else:
|
||||
return "Graduate level"
|
||||
|
||||
def _calculate_content_depth_score(self, word_count: int, vocabulary_diversity: float) -> int:
|
||||
"""Calculate content depth score"""
|
||||
score = 0
|
||||
|
||||
# Word count (optimal: 800-2000 words)
|
||||
if 800 <= word_count <= 2000:
|
||||
score += 50
|
||||
elif word_count < 800:
|
||||
score += 30
|
||||
else:
|
||||
score += 40
|
||||
|
||||
# Vocabulary diversity (optimal: 0.4-0.7)
|
||||
if 0.4 <= vocabulary_diversity <= 0.7:
|
||||
score += 50
|
||||
elif vocabulary_diversity < 0.4:
|
||||
score += 30
|
||||
else:
|
||||
score += 40
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _calculate_flow_score(self, transition_count: int, word_count: int) -> int:
|
||||
"""Calculate content flow score"""
|
||||
if word_count == 0:
|
||||
return 0
|
||||
|
||||
transition_density = transition_count / (word_count / 100)
|
||||
|
||||
# Optimal transition density: 1-3 per 100 words
|
||||
if 1 <= transition_density <= 3:
|
||||
return 90
|
||||
elif transition_density < 1:
|
||||
return 60
|
||||
else:
|
||||
return 70
|
||||
|
||||
def _calculate_heading_hierarchy_score(self, h1: List[str], h2: List[str], h3: List[str]) -> int:
|
||||
"""Calculate heading hierarchy score"""
|
||||
score = 0
|
||||
|
||||
# Should have exactly 1 H1
|
||||
if len(h1) == 1:
|
||||
score += 40
|
||||
elif len(h1) == 0:
|
||||
score += 20
|
||||
else:
|
||||
score += 10
|
||||
|
||||
# Should have 3-8 H2 headings
|
||||
if 3 <= len(h2) <= 8:
|
||||
score += 40
|
||||
elif len(h2) < 3:
|
||||
score += 20
|
||||
else:
|
||||
score += 30
|
||||
|
||||
# H3 headings are optional but good for structure
|
||||
if len(h3) > 0:
|
||||
score += 20
|
||||
|
||||
return min(score, 100)
|
||||
|
||||
def _calculate_keyword_score(self, keyword_analysis: Dict[str, Any]) -> int:
|
||||
"""Calculate keyword optimization score"""
|
||||
score = 0
|
||||
|
||||
# Check keyword density (optimal: 1-3%)
|
||||
densities = keyword_analysis.get('keyword_density', {})
|
||||
for keyword, density in densities.items():
|
||||
if 1 <= density <= 3:
|
||||
score += 30
|
||||
elif density < 1:
|
||||
score += 15
|
||||
else:
|
||||
score += 10
|
||||
|
||||
# Check keyword distribution
|
||||
distributions = keyword_analysis.get('keyword_distribution', {})
|
||||
for keyword, dist in distributions.items():
|
||||
if dist.get('in_headings', False):
|
||||
score += 20
|
||||
if dist.get('first_occurrence', -1) < 100: # Early occurrence
|
||||
score += 20
|
||||
|
||||
# Penalize missing keywords
|
||||
missing = len(keyword_analysis.get('missing_keywords', []))
|
||||
score -= missing * 10
|
||||
|
||||
# Penalize over-optimization
|
||||
over_opt = len(keyword_analysis.get('over_optimization', []))
|
||||
score -= over_opt * 15
|
||||
|
||||
return max(0, min(score, 100))
|
||||
|
||||
def _calculate_weighted_score(self, scores: Dict[str, int]) -> int:
|
||||
"""Calculate weighted overall score"""
|
||||
weights = {
|
||||
'structure': 0.2,
|
||||
'keywords': 0.25,
|
||||
'readability': 0.2,
|
||||
'quality': 0.15,
|
||||
'headings': 0.1,
|
||||
'ai_insights': 0.1
|
||||
}
|
||||
|
||||
weighted_sum = sum(scores.get(key, 0) * weight for key, weight in weights.items())
|
||||
return int(weighted_sum)
|
||||
|
||||
# Recommendation methods
|
||||
def _get_structure_recommendations(self, sections: int, has_intro: bool, has_conclusion: bool) -> List[str]:
|
||||
"""Get structure recommendations"""
|
||||
recommendations = []
|
||||
|
||||
if sections < 3:
|
||||
recommendations.append("Add more sections to improve content structure")
|
||||
elif sections > 8:
|
||||
recommendations.append("Consider combining some sections for better flow")
|
||||
|
||||
if not has_intro:
|
||||
recommendations.append("Add an introduction section to set context")
|
||||
|
||||
if not has_conclusion:
|
||||
recommendations.append("Add a conclusion section to summarize key points")
|
||||
|
||||
return recommendations
|
||||
|
||||
def _get_readability_recommendations(self, metrics: Dict[str, float], avg_sentence_length: float) -> List[str]:
|
||||
"""Get readability recommendations"""
|
||||
recommendations = []
|
||||
|
||||
flesch_score = metrics.get('flesch_reading_ease', 0)
|
||||
|
||||
if flesch_score < 60:
|
||||
recommendations.append("Simplify language and use shorter sentences")
|
||||
|
||||
if avg_sentence_length > 20:
|
||||
recommendations.append("Break down long sentences for better readability")
|
||||
|
||||
if flesch_score > 80:
|
||||
recommendations.append("Consider adding more technical depth for expert audience")
|
||||
|
||||
return recommendations
|
||||
|
||||
def _get_content_quality_recommendations(self, word_count: int, vocabulary_diversity: float, transition_count: int) -> List[str]:
|
||||
"""Get content quality recommendations"""
|
||||
recommendations = []
|
||||
|
||||
if word_count < 800:
|
||||
recommendations.append("Expand content with more detailed explanations")
|
||||
elif word_count > 2000:
|
||||
recommendations.append("Consider breaking into multiple posts")
|
||||
|
||||
if vocabulary_diversity < 0.4:
|
||||
recommendations.append("Use more varied vocabulary to improve engagement")
|
||||
|
||||
if transition_count < 3:
|
||||
recommendations.append("Add more transition words to improve flow")
|
||||
|
||||
return recommendations
|
||||
|
||||
def _get_heading_recommendations(self, h1: List[str], h2: List[str], h3: List[str]) -> List[str]:
|
||||
"""Get heading recommendations"""
|
||||
recommendations = []
|
||||
|
||||
if len(h1) == 0:
|
||||
recommendations.append("Add a main H1 heading")
|
||||
elif len(h1) > 1:
|
||||
recommendations.append("Use only one H1 heading per post")
|
||||
|
||||
if len(h2) < 3:
|
||||
recommendations.append("Add more H2 headings to structure content")
|
||||
elif len(h2) > 8:
|
||||
recommendations.append("Consider using H3 headings for better hierarchy")
|
||||
|
||||
return recommendations
|
||||
|
||||
async def _run_ai_analysis(self, blog_content: str, keywords_data: Dict[str, Any], non_ai_results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run single AI analysis for structured insights"""
|
||||
try:
|
||||
# Prepare context for AI analysis
|
||||
context = {
|
||||
'blog_content': blog_content,
|
||||
'keywords_data': keywords_data,
|
||||
'non_ai_results': non_ai_results
|
||||
}
|
||||
|
||||
# Create AI prompt for structured analysis
|
||||
prompt = self._create_ai_analysis_prompt(context)
|
||||
|
||||
# Get structured response from Gemini
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_quality_insights": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"engagement_score": {"type": "number"},
|
||||
"value_proposition": {"type": "string"},
|
||||
"content_gaps": {"type": "array", "items": {"type": "string"}},
|
||||
"improvement_suggestions": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"seo_optimization_insights": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword_optimization": {"type": "string"},
|
||||
"content_relevance": {"type": "string"},
|
||||
"search_intent_alignment": {"type": "string"},
|
||||
"seo_improvements": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"user_experience_insights": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_flow": {"type": "string"},
|
||||
"readability_assessment": {"type": "string"},
|
||||
"engagement_factors": {"type": "array", "items": {"type": "string"}},
|
||||
"ux_improvements": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"competitive_analysis": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_differentiation": {"type": "string"},
|
||||
"unique_value": {"type": "string"},
|
||||
"competitive_advantages": {"type": "array", "items": {"type": "string"}},
|
||||
"market_positioning": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ai_response = self.gemini_provider(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192
|
||||
)
|
||||
|
||||
return ai_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI analysis failed: {e}")
|
||||
# Fail fast - don't return mock data
|
||||
raise e
|
||||
|
||||
def _create_ai_analysis_prompt(self, context: Dict[str, Any]) -> str:
|
||||
"""Create AI analysis prompt"""
|
||||
blog_content = context['blog_content']
|
||||
keywords_data = context['keywords_data']
|
||||
non_ai_results = context['non_ai_results']
|
||||
|
||||
prompt = f"""
|
||||
Analyze this blog content for SEO optimization and user experience. Provide structured insights based on the content and keyword data.
|
||||
|
||||
BLOG CONTENT:
|
||||
{blog_content[:2000]}...
|
||||
|
||||
KEYWORDS DATA:
|
||||
Primary Keywords: {keywords_data.get('primary', [])}
|
||||
Long-tail Keywords: {keywords_data.get('long_tail', [])}
|
||||
Semantic Keywords: {keywords_data.get('semantic', [])}
|
||||
Search Intent: {keywords_data.get('search_intent', 'informational')}
|
||||
|
||||
NON-AI ANALYSIS RESULTS:
|
||||
Structure Score: {non_ai_results.get('content_structure', {}).get('structure_score', 0)}
|
||||
Readability Score: {non_ai_results.get('readability_analysis', {}).get('readability_score', 0)}
|
||||
Content Quality Score: {non_ai_results.get('content_quality', {}).get('content_depth_score', 0)}
|
||||
|
||||
Please provide:
|
||||
1. Content Quality Insights: Assess engagement potential, value proposition, content gaps, and improvement suggestions
|
||||
2. SEO Optimization Insights: Evaluate keyword optimization, content relevance, search intent alignment, and SEO improvements
|
||||
3. User Experience Insights: Analyze content flow, readability, engagement factors, and UX improvements
|
||||
4. Competitive Analysis: Identify content differentiation, unique value, competitive advantages, and market positioning
|
||||
|
||||
Focus on actionable insights that can improve the blog's performance and user engagement.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _compile_blog_seo_results(self, non_ai_results: Dict[str, Any], ai_insights: Dict[str, Any], keywords_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Compile comprehensive SEO analysis results"""
|
||||
try:
|
||||
# Validate required data - fail fast if missing
|
||||
if not non_ai_results:
|
||||
raise ValueError("Non-AI analysis results are missing")
|
||||
|
||||
if not ai_insights:
|
||||
raise ValueError("AI insights are missing")
|
||||
|
||||
# Calculate category scores
|
||||
category_scores = {
|
||||
'structure': non_ai_results.get('content_structure', {}).get('structure_score', 0),
|
||||
'keywords': self._calculate_keyword_score(non_ai_results.get('keyword_analysis', {})),
|
||||
'readability': non_ai_results.get('readability_analysis', {}).get('readability_score', 0),
|
||||
'quality': non_ai_results.get('content_quality', {}).get('content_depth_score', 0),
|
||||
'headings': non_ai_results.get('heading_structure', {}).get('heading_hierarchy_score', 0),
|
||||
'ai_insights': ai_insights.get('content_quality_insights', {}).get('engagement_score', 0)
|
||||
}
|
||||
|
||||
# Calculate overall score
|
||||
overall_score = self._calculate_weighted_score(category_scores)
|
||||
|
||||
# Compile actionable recommendations
|
||||
actionable_recommendations = self._compile_actionable_recommendations(non_ai_results, ai_insights)
|
||||
|
||||
# Create visualization data
|
||||
visualization_data = self._create_visualization_data(category_scores, non_ai_results)
|
||||
|
||||
return {
|
||||
'overall_score': overall_score,
|
||||
'category_scores': category_scores,
|
||||
'detailed_analysis': non_ai_results,
|
||||
'ai_insights': ai_insights,
|
||||
'keywords_data': keywords_data,
|
||||
'visualization_data': visualization_data,
|
||||
'actionable_recommendations': actionable_recommendations,
|
||||
'generated_at': datetime.utcnow().isoformat(),
|
||||
'analysis_summary': self._create_analysis_summary(overall_score, category_scores, ai_insights)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Results compilation failed: {e}")
|
||||
# Fail fast - don't return fallback data
|
||||
raise e
|
||||
|
||||
def _compile_actionable_recommendations(self, non_ai_results: Dict[str, Any], ai_insights: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Compile actionable recommendations from all sources"""
|
||||
recommendations = []
|
||||
|
||||
# Structure recommendations
|
||||
structure_recs = non_ai_results.get('content_structure', {}).get('recommendations', [])
|
||||
for rec in structure_recs:
|
||||
recommendations.append({
|
||||
'category': 'Structure',
|
||||
'priority': 'High',
|
||||
'recommendation': rec,
|
||||
'impact': 'Improves content organization and user experience'
|
||||
})
|
||||
|
||||
# Keyword recommendations
|
||||
keyword_recs = non_ai_results.get('keyword_analysis', {}).get('recommendations', [])
|
||||
for rec in keyword_recs:
|
||||
recommendations.append({
|
||||
'category': 'Keywords',
|
||||
'priority': 'High',
|
||||
'recommendation': rec,
|
||||
'impact': 'Improves search engine visibility'
|
||||
})
|
||||
|
||||
# Readability recommendations
|
||||
readability_recs = non_ai_results.get('readability_analysis', {}).get('recommendations', [])
|
||||
for rec in readability_recs:
|
||||
recommendations.append({
|
||||
'category': 'Readability',
|
||||
'priority': 'Medium',
|
||||
'recommendation': rec,
|
||||
'impact': 'Improves user engagement and comprehension'
|
||||
})
|
||||
|
||||
# AI insights recommendations
|
||||
ai_recs = ai_insights.get('content_quality_insights', {}).get('improvement_suggestions', [])
|
||||
for rec in ai_recs:
|
||||
recommendations.append({
|
||||
'category': 'Content Quality',
|
||||
'priority': 'Medium',
|
||||
'recommendation': rec,
|
||||
'impact': 'Enhances content value and engagement'
|
||||
})
|
||||
|
||||
return recommendations
|
||||
|
||||
def _create_visualization_data(self, category_scores: Dict[str, int], non_ai_results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create data for visualization components"""
|
||||
return {
|
||||
'score_radar': {
|
||||
'categories': list(category_scores.keys()),
|
||||
'scores': list(category_scores.values()),
|
||||
'max_score': 100
|
||||
},
|
||||
'keyword_analysis': {
|
||||
'densities': non_ai_results.get('keyword_analysis', {}).get('keyword_density', {}),
|
||||
'missing_keywords': non_ai_results.get('keyword_analysis', {}).get('missing_keywords', []),
|
||||
'over_optimization': non_ai_results.get('keyword_analysis', {}).get('over_optimization', [])
|
||||
},
|
||||
'readability_metrics': non_ai_results.get('readability_analysis', {}).get('metrics', {}),
|
||||
'content_stats': {
|
||||
'word_count': non_ai_results.get('content_quality', {}).get('word_count', 0),
|
||||
'sections': non_ai_results.get('content_structure', {}).get('total_sections', 0),
|
||||
'paragraphs': non_ai_results.get('content_structure', {}).get('total_paragraphs', 0)
|
||||
}
|
||||
}
|
||||
|
||||
def _create_analysis_summary(self, overall_score: int, category_scores: Dict[str, int], ai_insights: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create analysis summary"""
|
||||
# Determine overall grade
|
||||
if overall_score >= 90:
|
||||
grade = 'A'
|
||||
status = 'Excellent'
|
||||
elif overall_score >= 80:
|
||||
grade = 'B'
|
||||
status = 'Good'
|
||||
elif overall_score >= 70:
|
||||
grade = 'C'
|
||||
status = 'Fair'
|
||||
elif overall_score >= 60:
|
||||
grade = 'D'
|
||||
status = 'Needs Improvement'
|
||||
else:
|
||||
grade = 'F'
|
||||
status = 'Poor'
|
||||
|
||||
# Find strongest and weakest categories
|
||||
strongest_category = max(category_scores.items(), key=lambda x: x[1])
|
||||
weakest_category = min(category_scores.items(), key=lambda x: x[1])
|
||||
|
||||
return {
|
||||
'overall_grade': grade,
|
||||
'status': status,
|
||||
'strongest_category': strongest_category[0],
|
||||
'weakest_category': weakest_category[0],
|
||||
'key_strengths': self._identify_key_strengths(category_scores),
|
||||
'key_weaknesses': self._identify_key_weaknesses(category_scores),
|
||||
'ai_summary': ai_insights.get('content_quality_insights', {}).get('value_proposition', '')
|
||||
}
|
||||
|
||||
def _identify_key_strengths(self, category_scores: Dict[str, int]) -> List[str]:
|
||||
"""Identify key strengths"""
|
||||
strengths = []
|
||||
|
||||
for category, score in category_scores.items():
|
||||
if score >= 80:
|
||||
strengths.append(f"Strong {category} optimization")
|
||||
|
||||
return strengths
|
||||
|
||||
def _identify_key_weaknesses(self, category_scores: Dict[str, int]) -> List[str]:
|
||||
"""Identify key weaknesses"""
|
||||
weaknesses = []
|
||||
|
||||
for category, score in category_scores.items():
|
||||
if score < 60:
|
||||
weaknesses.append(f"Needs improvement in {category}")
|
||||
|
||||
return weaknesses
|
||||
|
||||
def _create_error_result(self, error_message: str) -> Dict[str, Any]:
|
||||
"""Create error result - this should not be used in fail-fast mode"""
|
||||
raise ValueError(f"Error result creation not allowed in fail-fast mode: {error_message}")
|
||||
131
backend/test_seo_analyzer.py
Normal file
131
backend/test_seo_analyzer.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Test script for Blog Content SEO Analyzer
|
||||
|
||||
This script tests the core functionality of the SEO analyzer
|
||||
without requiring the full application setup.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the backend directory to the Python path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
|
||||
|
||||
from services.blog_writer.seo.blog_content_seo_analyzer import BlogContentSEOAnalyzer
|
||||
|
||||
|
||||
async def test_seo_analyzer():
|
||||
"""Test the SEO analyzer with sample data"""
|
||||
|
||||
# Sample blog content
|
||||
sample_content = """
|
||||
# The Ultimate Guide to AI-Powered Blog Writing
|
||||
|
||||
## Introduction
|
||||
|
||||
In today's digital landscape, content creation has become more important than ever. With the rise of artificial intelligence, we're seeing revolutionary changes in how we approach blog writing and content marketing.
|
||||
|
||||
## What is AI-Powered Blog Writing?
|
||||
|
||||
AI-powered blog writing refers to the use of artificial intelligence tools and technologies to assist in the creation, optimization, and management of blog content. This includes everything from research and outline generation to content creation and SEO optimization.
|
||||
|
||||
## Key Benefits of AI Blog Writing
|
||||
|
||||
### 1. Increased Efficiency
|
||||
AI tools can significantly reduce the time required to create high-quality blog content. What used to take hours can now be completed in minutes.
|
||||
|
||||
### 2. Improved SEO Performance
|
||||
AI-powered tools can analyze search trends, identify optimal keywords, and ensure content is optimized for search engines.
|
||||
|
||||
### 3. Enhanced Content Quality
|
||||
With AI assistance, writers can focus on strategy and creativity while AI handles the technical aspects of content creation.
|
||||
|
||||
## Best Practices for AI Blog Writing
|
||||
|
||||
1. **Start with Research**: Use AI tools to gather comprehensive information about your topic
|
||||
2. **Create Detailed Outlines**: Leverage AI to structure your content effectively
|
||||
3. **Optimize for SEO**: Use AI analysis to ensure your content ranks well
|
||||
4. **Review and Refine**: Always review AI-generated content before publishing
|
||||
|
||||
## Conclusion
|
||||
|
||||
AI-powered blog writing is transforming the content creation landscape. By leveraging these tools effectively, content creators can produce higher quality content more efficiently than ever before.
|
||||
|
||||
The future of content creation is here, and it's powered by artificial intelligence.
|
||||
"""
|
||||
|
||||
# Sample research data
|
||||
sample_research_data = {
|
||||
"keyword_analysis": {
|
||||
"primary": ["AI blog writing", "artificial intelligence content", "AI content creation"],
|
||||
"long_tail": ["AI-powered blog writing tools", "artificial intelligence content marketing", "AI blog writing software"],
|
||||
"semantic": ["content automation", "AI writing assistant", "automated content creation", "AI content optimization"],
|
||||
"all_keywords": ["AI blog writing", "artificial intelligence content", "AI content creation", "AI-powered blog writing tools", "artificial intelligence content marketing", "AI blog writing software", "content automation", "AI writing assistant", "automated content creation", "AI content optimization"],
|
||||
"search_intent": "informational"
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"top_competitors": ["HubSpot", "Content Marketing Institute", "Copyblogger"],
|
||||
"content_gaps": ["AI-specific use cases", "ROI measurement", "implementation strategies"]
|
||||
},
|
||||
"content_angles": [
|
||||
"Beginner's guide to AI blog writing",
|
||||
"ROI of AI content creation tools",
|
||||
"AI vs human content creation comparison"
|
||||
]
|
||||
}
|
||||
|
||||
print("🚀 Starting SEO Analysis Test")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Initialize the analyzer
|
||||
analyzer = BlogContentSEOAnalyzer()
|
||||
print("✅ SEO Analyzer initialized successfully")
|
||||
|
||||
# Run the analysis
|
||||
print("\n📊 Running SEO analysis...")
|
||||
results = await analyzer.analyze_blog_content(sample_content, sample_research_data)
|
||||
|
||||
# Display results
|
||||
print("\n📈 Analysis Results:")
|
||||
print("=" * 30)
|
||||
|
||||
if 'error' in results:
|
||||
print(f"❌ Analysis failed: {results['error']}")
|
||||
return
|
||||
|
||||
print(f"🎯 Overall Score: {results.get('overall_score', 0)}/100")
|
||||
print(f"📊 Overall Grade: {results.get('analysis_summary', {}).get('overall_grade', 'N/A')}")
|
||||
print(f"📝 Status: {results.get('analysis_summary', {}).get('status', 'N/A')}")
|
||||
|
||||
print("\n📋 Category Scores:")
|
||||
category_scores = results.get('category_scores', {})
|
||||
for category, score in category_scores.items():
|
||||
print(f" • {category.capitalize()}: {score}/100")
|
||||
|
||||
print("\n💡 Key Strengths:")
|
||||
strengths = results.get('analysis_summary', {}).get('key_strengths', [])
|
||||
for strength in strengths:
|
||||
print(f" ✅ {strength}")
|
||||
|
||||
print("\n⚠️ Areas for Improvement:")
|
||||
weaknesses = results.get('analysis_summary', {}).get('key_weaknesses', [])
|
||||
for weakness in weaknesses:
|
||||
print(f" 🔧 {weakness}")
|
||||
|
||||
print("\n📝 Actionable Recommendations:")
|
||||
recommendations = results.get('actionable_recommendations', [])
|
||||
for i, rec in enumerate(recommendations[:5], 1): # Show first 5 recommendations
|
||||
print(f" {i}. [{rec.get('category', 'N/A')}] {rec.get('recommendation', 'N/A')}")
|
||||
|
||||
print("\n🎉 SEO Analysis completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_seo_analyzer())
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling } from '../../hooks/usePolling';
|
||||
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
@@ -18,14 +19,19 @@ import { CustomOutlineForm } from './CustomOutlineForm';
|
||||
import { ResearchDataActions } from './ResearchDataActions';
|
||||
import { EnhancedOutlineActions } from './EnhancedOutlineActions';
|
||||
import HallucinationChecker from './HallucinationChecker';
|
||||
import { RewriteFeedbackForm } from './RewriteFeedbackForm';
|
||||
import Publisher from './Publisher';
|
||||
import OutlineGenerator from './OutlineGenerator';
|
||||
import OutlineRefiner from './OutlineRefiner';
|
||||
import SEOProcessor from './SEOProcessor';
|
||||
import { SEOProcessor } from './SEO';
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
import { OutlineProgressModal } from './OutlineProgressModal';
|
||||
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
||||
import { BlogEditor } from './WYSIWYG';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
|
||||
// Type assertion for CopilotKit action
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
// Use custom hook for all state management
|
||||
@@ -47,13 +53,20 @@ export const BlogWriter: React.FC = () => {
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
setSeoMetadata,
|
||||
setContinuityRefresh,
|
||||
setOutlineTaskId,
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
@@ -107,6 +120,24 @@ export const BlogWriter: React.FC = () => {
|
||||
onError: (err) => console.error('Medium generation failed:', err)
|
||||
});
|
||||
|
||||
// Rewrite polling hook (used for blog rewrite operations)
|
||||
const rewritePolling = useRewritePolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
setSections(newSections);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply rewrite result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Rewrite failed:', err)
|
||||
});
|
||||
|
||||
// Get context-aware suggestions based on current task status
|
||||
const suggestions = useSuggestions(
|
||||
research,
|
||||
@@ -114,19 +145,26 @@ export const BlogWriter: React.FC = () => {
|
||||
outlineConfirmed,
|
||||
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
|
||||
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
|
||||
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }
|
||||
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
|
||||
Object.keys(sections).length > 0, // hasContent
|
||||
flowAnalysisCompleted, // flowAnalysisCompleted state
|
||||
contentConfirmed // contentConfirmed state
|
||||
);
|
||||
|
||||
// Add minimum display time for modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||
|
||||
// SEO Analysis Modal state
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
setShowModal(true);
|
||||
setModalStartTime(Date.now());
|
||||
} else if (!mediumPolling.isPolling && !isMediumGenerationStarting && showModal) {
|
||||
} else if (!mediumPolling.isPolling && !rewritePolling.isPolling && !isMediumGenerationStarting && showModal) {
|
||||
const elapsed = Date.now() - (modalStartTime || 0);
|
||||
const minDisplayTime = 2000; // 2 seconds minimum
|
||||
|
||||
@@ -140,7 +178,19 @@ export const BlogWriter: React.FC = () => {
|
||||
setModalStartTime(null);
|
||||
}
|
||||
}
|
||||
}, [mediumPolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
|
||||
// Handle outline modal visibility
|
||||
useEffect(() => {
|
||||
if (outlinePolling.isPolling && !showOutlineModal) {
|
||||
setShowOutlineModal(true);
|
||||
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
||||
// Add a small delay to ensure user sees completion message
|
||||
setTimeout(() => {
|
||||
setShowOutlineModal(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [outlinePolling.isPolling, showOutlineModal]);
|
||||
|
||||
// Handle medium generation start from OutlineFeedbackForm
|
||||
const handleMediumGenerationStarted = (taskId: string) => {
|
||||
@@ -162,6 +212,62 @@ export const BlogWriter: React.FC = () => {
|
||||
progressCount: mediumPolling.progressMessages.length
|
||||
});
|
||||
|
||||
// Debug SEO modal state
|
||||
console.log('🔍 SEO Analysis Modal state:', {
|
||||
isSEOAnalysisModalOpen,
|
||||
hasResearch: !!research,
|
||||
hasContent: !!sections && Object.keys(sections).length > 0,
|
||||
researchKeys: research ? Object.keys(research) : [],
|
||||
sectionsKeys: sections ? Object.keys(sections) : []
|
||||
});
|
||||
|
||||
// Debug action registration
|
||||
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
|
||||
|
||||
// Copilot action for confirming blog content
|
||||
useCopilotActionTyped({
|
||||
name: "confirmBlogContent",
|
||||
description: "Confirm that the blog content is ready and move to the next stage (SEO analysis)",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
return "Blog content has been confirmed! You can now proceed with SEO analysis and publishing.";
|
||||
}
|
||||
});
|
||||
|
||||
// Copilot action for running SEO analysis
|
||||
useCopilotActionTyped({
|
||||
name: "analyzeSEO",
|
||||
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('🚀 SEO Analysis Action Triggered!');
|
||||
console.log('Current modal state before:', isSEOAnalysisModalOpen);
|
||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
||||
|
||||
// Check if we have content to analyze
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
console.log('❌ No content available for SEO analysis');
|
||||
return "No blog content available for SEO analysis. Please generate content first.";
|
||||
}
|
||||
|
||||
// Check if we have research data
|
||||
if (!research || !research.keyword_analysis) {
|
||||
console.log('❌ No research data available for SEO analysis');
|
||||
return "Research data is required for SEO analysis. Please run research first.";
|
||||
}
|
||||
|
||||
// Open SEO analysis modal
|
||||
console.log('✅ All checks passed, opening SEO analysis modal');
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
console.log('Modal state set to true');
|
||||
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,13 +297,41 @@ export const BlogWriter: React.FC = () => {
|
||||
onOutlineRefined={handleOutlineRefined}
|
||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle}
|
||||
onFlowAnalysisComplete={(analysis) => {
|
||||
console.log('Flow analysis completed:', analysis);
|
||||
setFlowAnalysisCompleted(true);
|
||||
setFlowAnalysisResults(analysis);
|
||||
// Trigger a refresh of continuity badges
|
||||
setContinuityRefresh((prev: number) => (prev || 0) + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Rewrite Feedback Form - Only show when content exists */}
|
||||
{Object.keys(sections).length > 0 && (
|
||||
<RewriteFeedbackForm
|
||||
research={research!}
|
||||
outline={outline}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle}
|
||||
onRewriteStarted={(taskId) => {
|
||||
console.log('Starting rewrite polling for task:', taskId);
|
||||
rewritePolling.startPolling(taskId);
|
||||
}}
|
||||
onRewriteTriggered={() => {
|
||||
console.log('Rewrite triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
onModalShow={() => setShowOutlineModal(true)}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
@@ -239,17 +373,19 @@ export const BlogWriter: React.FC = () => {
|
||||
<div>
|
||||
{outlineConfirmed ? (
|
||||
/* WYSIWYG Editor - Show when outline is confirmed */
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
/>
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
/* Outline Editor - Show when outline is not confirmed */
|
||||
<>
|
||||
@@ -374,9 +510,9 @@ Available tools:
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- runSEOAnalyze(keywords?: string)
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- runHallucinationCheck()
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
@@ -392,16 +528,26 @@ Available tools:
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user clicks "Confirm & Generate Content", ONLY call confirmOutlineAndGenerateContent() - DO NOT automatically generate content
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → SEO → Publish
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
@@ -415,21 +561,36 @@ Available tools:
|
||||
{/* Outline Progress Modal */}
|
||||
{/* Outline modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={outlinePolling.isPolling}
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error}
|
||||
/>
|
||||
|
||||
{/* Medium generation modal */}
|
||||
{/* Medium generation / Rewrite modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={mediumPolling.currentStatus}
|
||||
progressMessages={mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''}
|
||||
error={mediumPolling.error}
|
||||
titleOverride={'📝 Generating Your Blog Content'}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ?
|
||||
(rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : '') :
|
||||
(mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : '')
|
||||
}
|
||||
error={rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
/>
|
||||
|
||||
{/* SEO Analysis Modal */}
|
||||
<SEOAnalysisModal
|
||||
isOpen={isSEOAnalysisModalOpen}
|
||||
onClose={() => setIsSEOAnalysisModalOpen(false)}
|
||||
blogContent={buildFullMarkdown()}
|
||||
researchData={research}
|
||||
onApplyRecommendations={(recommendations) => {
|
||||
console.log('Applying SEO recommendations:', recommendations);
|
||||
// TODO: Implement recommendation application logic
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props { sectionId: string; refreshToken?: number }
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
refreshToken?: number;
|
||||
disabled?: boolean;
|
||||
flowAnalysisResults?: any;
|
||||
}
|
||||
|
||||
export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken }) => {
|
||||
export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disabled = false, flowAnalysisResults }) => {
|
||||
const [metrics, setMetrics] = useState<Record<string, number> | null>(null);
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// If we have flow analysis results, use them instead of API call
|
||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
||||
console.log('🔍 [ContinuityBadge] Flow analysis results available:', flowAnalysisResults);
|
||||
console.log('🔍 [ContinuityBadge] Looking for section ID:', sectionId);
|
||||
console.log('🔍 [ContinuityBadge] Available section IDs:', flowAnalysisResults.sections.map((s: any) => s.section_id));
|
||||
|
||||
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||
if (sectionAnalysis) {
|
||||
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
|
||||
if (mounted) {
|
||||
setMetrics({
|
||||
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
|
||||
consistency: sectionAnalysis.consistency_score,
|
||||
progression: sectionAnalysis.progression_score
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if no flow analysis results
|
||||
console.log('🔍 [ContinuityBadge] Fetching continuity for section:', sectionId);
|
||||
blogWriterApi.getContinuity(sectionId)
|
||||
.then(res => { if (mounted) setMetrics(res.continuity_metrics || null); })
|
||||
.catch(() => { /* ignore */ });
|
||||
.then(res => {
|
||||
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
|
||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
|
||||
/* ignore */
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [sectionId, refreshToken]);
|
||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||
|
||||
if (!metrics) return null;
|
||||
const flow = Math.round(((metrics.flow || 0) * 100));
|
||||
const color = flow >= 80 ? '#2e7d32' : flow >= 60 ? '#f9a825' : '#c62828';
|
||||
|
||||
const consistency = Math.round(((metrics.consistency || 0) * 100));
|
||||
const progression = Math.round(((metrics.progression || 0) * 100));
|
||||
// Show badge even if metrics are null (for debugging)
|
||||
const flow = metrics ? Math.round(((metrics.flow || 0) * 100)) : 0;
|
||||
const consistency = metrics ? Math.round(((metrics.consistency || 0) * 100)) : 0;
|
||||
const progression = metrics ? Math.round(((metrics.progression || 0) * 100)) : 0;
|
||||
|
||||
// Enable badge if we have flow analysis results or metrics
|
||||
const isEnabled = !disabled || (flowAnalysisResults && flowAnalysisResults.sections) || metrics;
|
||||
|
||||
// Enhanced color coding with actionable feedback
|
||||
const getFlowColor = (score: number) => {
|
||||
if (score >= 80) return '#2e7d32'; // Green - Excellent
|
||||
if (score >= 60) return '#f9a825'; // Yellow - Good
|
||||
return '#c62828'; // Red - Needs improvement
|
||||
};
|
||||
|
||||
const getFlowSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🎉 Excellent narrative flow!";
|
||||
if (score >= 60) return "💡 Good flow - try connecting ideas more smoothly";
|
||||
return "🔧 Consider adding transitions between paragraphs";
|
||||
};
|
||||
|
||||
const getConsistencySuggestion = (score: number) => {
|
||||
if (score >= 80) return "✨ Consistent tone and style";
|
||||
if (score >= 60) return "📝 Good consistency - maintain your voice";
|
||||
return "🎯 Work on maintaining consistent tone throughout";
|
||||
};
|
||||
|
||||
const getProgressionSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🚀 Great logical progression!";
|
||||
if (score >= 60) return "📈 Good progression - build on previous points";
|
||||
return "🔗 Strengthen connections between ideas";
|
||||
};
|
||||
|
||||
const color = getFlowColor(flow);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -29,21 +93,23 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken }) =>
|
||||
style={{ position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<span
|
||||
title={`Flow ${flow}%`}
|
||||
title={!isEnabled ? 'Flow analysis disabled - use Copilot to enable' : (metrics ? `Flow ${flow}%` : 'Flow metrics not available')}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
border: `1px solid ${color}`,
|
||||
color: !isEnabled ? '#999' : (metrics ? color : '#666'),
|
||||
border: `1px solid ${!isEnabled ? '#ddd' : (metrics ? color : '#ccc')}`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 10,
|
||||
background: 'transparent'
|
||||
background: !isEnabled ? '#f5f5f5' : 'transparent',
|
||||
cursor: !isEnabled ? 'not-allowed' : 'default',
|
||||
opacity: !isEnabled ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
Flow {flow}%
|
||||
{!isEnabled ? 'Flow --' : (metrics ? `Flow ${flow}%` : 'Flow --')}
|
||||
</span>
|
||||
|
||||
{hover && (
|
||||
{hover && isEnabled && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -53,21 +119,61 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken }) =>
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
minWidth: 180,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)'
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
minWidth: 280,
|
||||
maxWidth: 320,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, marginBottom: 6 }}>Continuity</div>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Flow</span><span>{flow}%</span>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12, color: '#1a1a1a' }}>
|
||||
📊 Content Quality Analysis
|
||||
</div>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Consistency</span><span>{consistency}%</span>
|
||||
|
||||
{/* Flow Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Flow</span>
|
||||
<span style={{ color: getFlowColor(flow), fontWeight: 600 }}>{flow}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getFlowSuggestion(flow)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Progression</span><span>{progression}%</span>
|
||||
|
||||
{/* Consistency Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Consistency</span>
|
||||
<span style={{ color: getFlowColor(consistency), fontWeight: 600 }}>{consistency}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getConsistencySuggestion(consistency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progression Analysis */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Progression</span>
|
||||
<span style={{ color: getFlowColor(progression), fontWeight: 600 }}>{progression}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getProgressionSuggestion(progression)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Quality Indicator */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 8,
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
💡 Hover over other sections to compare quality metrics
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,9 @@ interface OutlineFeedbackFormProps {
|
||||
onOutlineRefined: (feedback: string) => void;
|
||||
onMediumGenerationStarted?: (taskId: string) => void;
|
||||
onMediumGenerationTriggered?: () => void;
|
||||
sections?: Record<string, string>;
|
||||
blogTitle?: string;
|
||||
onFlowAnalysisComplete?: (analysis: any) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,13 +223,16 @@ const FeedbackForm: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
onOutlineConfirmed,
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
onMediumGenerationTriggered
|
||||
onMediumGenerationTriggered,
|
||||
sections,
|
||||
blogTitle,
|
||||
onFlowAnalysisComplete
|
||||
}) => {
|
||||
|
||||
// Refine outline action with HITL
|
||||
@@ -492,6 +498,181 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Flow Analysis Actions
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQuality',
|
||||
description: 'Analyze the flow and quality of blog content to get improvement suggestions (basic analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running quality analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call basic flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowBasic({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Content quality analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
suggestions: s.suggestions
|
||||
})),
|
||||
overall_suggestions: analysis.overall_suggestions
|
||||
},
|
||||
next_step_suggestion: 'Use "🔍 Deep Content Analysis" for detailed, section-by-section analysis with more specific recommendations.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Content quality analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Content quality analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to analyze content quality: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQualityAdvanced',
|
||||
description: 'Get detailed, section-by-section analysis of content quality and flow (advanced analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for advanced analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running advanced analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for advanced analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call advanced flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowAdvanced({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Advanced content analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
detailed_analysis: s.detailed_analysis,
|
||||
suggestions: s.suggestions
|
||||
}))
|
||||
},
|
||||
next_step_suggestion: 'Review the detailed analysis and implement the suggested improvements to enhance your content quality.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Advanced content analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Advanced content analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to perform advanced content analysis: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ interface OutlineGeneratorProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
onModalShow?: () => void; // Callback to show progress modal immediately
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -13,7 +14,8 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart
|
||||
onPollingStart,
|
||||
onModalShow
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
@@ -23,8 +25,14 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
|
||||
try {
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
onModalShow?.();
|
||||
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
|
||||
// Start polling immediately after getting task_id
|
||||
// This ensures we catch progress messages from the very beginning
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
|
||||
|
||||
381
frontend/src/components/BlogWriter/RewriteFeedbackForm.tsx
Normal file
381
frontend/src/components/BlogWriter/RewriteFeedbackForm.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
// Type assertion for CopilotKit action
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// Separate component to manage rewrite feedback form state
|
||||
const RewriteFeedbackFormComponent: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { feedback: string; tone?: string; audience?: string; focus?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [tone, setTone] = useState('');
|
||||
const [audience, setAudience] = useState('');
|
||||
const [focus, setFocus] = useState('');
|
||||
const hasValidInput = feedback.trim().length >= 10;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({
|
||||
feedback: feedback.trim(),
|
||||
tone: tone.trim() || undefined,
|
||||
audience: audience.trim() || undefined,
|
||||
focus: focus.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
window.alert('Please provide detailed feedback about what you want to change (at least 10 characters).');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🔄 Let's Rewrite Your Blog
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please provide feedback about what you\'d like to change in your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
What do you want to change? *
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="e.g., I want to focus more on practical applications, make the tone more casual, emphasize real-world examples, etc."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
{feedback.length}/10 characters minimum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Desired Tone (optional)
|
||||
</label>
|
||||
<select
|
||||
value={tone}
|
||||
onChange={(e) => setTone(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="">Keep current tone</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="authoritative">Authoritative</option>
|
||||
<option value="conversational">Conversational</option>
|
||||
<option value="humorous">Humorous</option>
|
||||
<option value="empathetic">Empathetic</option>
|
||||
<option value="academic">Academic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Audience (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
placeholder="e.g., beginners, professionals, students, general audience"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Main Focus/Angle (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={(e) => setFocus(e.target.value)}
|
||||
placeholder="e.g., practical applications, technical deep-dive, beginner-friendly, industry trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🔄 Rewrite Blog {hasValidInput ? '(Enabled)' : '(Disabled)'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface RewriteFeedbackFormProps {
|
||||
research: BlogResearchResponse;
|
||||
outline: BlogOutlineSection[];
|
||||
sections: Record<string, string>;
|
||||
blogTitle: string;
|
||||
onRewriteStarted?: (taskId: string) => void;
|
||||
onRewriteTriggered?: () => void;
|
||||
}
|
||||
|
||||
export const RewriteFeedbackForm: React.FC<RewriteFeedbackFormProps> = ({
|
||||
research,
|
||||
outline,
|
||||
sections,
|
||||
blogTitle,
|
||||
onRewriteStarted,
|
||||
onRewriteTriggered
|
||||
}) => {
|
||||
const [isCollectingFeedback, setIsCollectingFeedback] = useState(false);
|
||||
|
||||
// Rewrite Blog Action with HITL
|
||||
useCopilotActionTyped({
|
||||
name: 'rewriteBlog',
|
||||
description: 'Rewrite the entire blog based on user feedback and preferences',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Rewrite feedback received! Starting blog rewrite...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RewriteFeedbackFormComponent
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
onRewriteTriggered?.();
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Process Rewrite Feedback Action
|
||||
useCopilotActionTyped({
|
||||
name: 'processRewriteFeedback',
|
||||
description: 'Process the rewrite feedback and start the blog rewrite task',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with feedback, tone, audience, and focus', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { feedback, tone, audience, focus } = data;
|
||||
|
||||
if (!feedback || feedback.trim().length < 10) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please provide more detailed feedback about what you\'d like to change.',
|
||||
suggestion: 'Be specific about what aspects of the blog you want to improve, change, or rewrite.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare the rewrite request
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content found to rewrite. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog before attempting to rewrite it.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call the rewrite API
|
||||
const result = await blogWriterApi.rewriteBlog({
|
||||
title: blogTitle,
|
||||
sections: sectionsData,
|
||||
research: research,
|
||||
outline: outline,
|
||||
feedback: feedback.trim(),
|
||||
tone: tone?.trim() || undefined,
|
||||
audience: audience?.trim() || undefined,
|
||||
focus: focus?.trim() || undefined
|
||||
});
|
||||
|
||||
if (result.success && result.taskId) {
|
||||
onRewriteStarted?.(result.taskId);
|
||||
setIsCollectingFeedback(false);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Blog rewrite initiated successfully! Your feedback has been processed and the rewrite is in progress.`,
|
||||
taskId: result.taskId,
|
||||
feedback: {
|
||||
original: feedback,
|
||||
tone: tone || 'Maintain current tone',
|
||||
audience: audience || 'Keep current audience',
|
||||
focus: focus || 'Maintain current focus'
|
||||
},
|
||||
nextStep: 'The rewrite process will take a few moments. You\'ll be notified when it\'s complete.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to initiate blog rewrite.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Collect rewrite feedback error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to process rewrite feedback: ${errorMessage}`,
|
||||
suggestion: 'Please try again or provide more specific feedback about what you\'d like to change.'
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔄 Rewriting Your Blog</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing your feedback and preferences...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Processing current content structure...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Generating improved content with new approach...</p>
|
||||
<p style={{ margin: '0' }}>• Applying tone and audience adjustments...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component doesn't render anything, it just provides actions
|
||||
};
|
||||
|
||||
export default RewriteFeedbackForm;
|
||||
240
frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
Normal file
240
frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Keyword Analysis Component
|
||||
*
|
||||
* Displays comprehensive keyword analysis including keyword types, densities,
|
||||
* missing keywords, over-optimization, and distribution analysis.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
GpsFixed,
|
||||
Search,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface KeywordAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
keyword_analysis?: {
|
||||
primary_keywords: string[];
|
||||
long_tail_keywords: string[];
|
||||
semantic_keywords: string[];
|
||||
keyword_density: Record<string, number>;
|
||||
keyword_distribution: Record<string, any>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GpsFixed sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Keyword Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Keyword Types Overview */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Keyword Types Found
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
Primary Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
Long-tail Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
Semantic Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Keyword Densities */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Keyword Densities
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Keyword Density Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Shows how frequently each keyword appears in your content as a percentage of total words.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 1-3% for primary keywords
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Too Low (<1%):</strong> Keyword may not be prominent enough
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<Search />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
|
||||
<Box key={keyword} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{keyword}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${density.toFixed(1)}%`}
|
||||
color={density > 3 ? 'error' : density < 1 ? 'warning' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
No keyword density data available. Make sure your research data includes target keywords.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Missing Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'error.main' }}>
|
||||
Missing Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'error.main' }}>
|
||||
<Warning />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Over-Optimized Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'warning.main' }}>
|
||||
Over-Optimized Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'warning.main' }}>
|
||||
<Warning />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Keyword Distribution Analysis */}
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Keyword Distribution Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
"{keyword}"
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Density: {data.density?.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
First Occurrence: Character {data.first_occurrence || 'Not found'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
103
frontend/src/components/BlogWriter/SEO/README.md
Normal file
103
frontend/src/components/BlogWriter/SEO/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SEO Components
|
||||
|
||||
This folder contains extracted SEO analysis components that were refactored from the main `SEOAnalysisModal` component to improve maintainability and code organization.
|
||||
|
||||
## Components
|
||||
|
||||
### KeywordAnalysis
|
||||
- **File**: `KeywordAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive keyword analysis including:
|
||||
- Keyword types overview (primary, long-tail, semantic)
|
||||
- Keyword density analysis with optimal range indicators
|
||||
- Missing keywords detection
|
||||
- Over-optimized keywords detection
|
||||
- Keyword distribution analysis
|
||||
|
||||
### ReadabilityAnalysis
|
||||
- **File**: `ReadabilityAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive readability analysis including:
|
||||
- 6 different readability metrics with tooltips
|
||||
- Content statistics (word count, sections, paragraphs, etc.)
|
||||
- Sentence and paragraph analysis
|
||||
- Target audience determination
|
||||
- Content quality metrics
|
||||
|
||||
### StructureAnalysis
|
||||
- **File**: `StructureAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive content structure analysis including:
|
||||
- Structure overview (sections, paragraphs, sentences, structure score)
|
||||
- Content elements detection (introduction, conclusion, call-to-action)
|
||||
- Heading structure analysis (H1, H2, H3 counts and actual headings)
|
||||
- Heading hierarchy score
|
||||
|
||||
### Recommendations
|
||||
- **File**: `Recommendations.tsx`
|
||||
- **Purpose**: Displays actionable SEO recommendations including:
|
||||
- Priority-based recommendation cards (High, Medium, Low)
|
||||
- Category tags for each recommendation
|
||||
- Impact descriptions
|
||||
- Visual priority indicators with icons
|
||||
|
||||
### SEOProcessor
|
||||
- **File**: `SEOProcessor.tsx`
|
||||
- **Purpose**: Provides CopilotKit actions for SEO functionality including:
|
||||
- `generateSEOMetadata` - Generate SEO metadata for blog content
|
||||
- `optimizeSection` - Optimize individual sections for SEO
|
||||
- Interactive UI components for user feedback
|
||||
|
||||
## Refactoring Benefits
|
||||
|
||||
1. **Improved Maintainability**: Each component is focused on a single responsibility
|
||||
2. **Better Code Organization**: Related functionality is grouped together
|
||||
3. **Easier Testing**: Individual components can be tested in isolation
|
||||
4. **Reusability**: Components can be reused in other parts of the application
|
||||
5. **Reduced File Size**: Main modal component reduced by ~600+ lines
|
||||
6. **Modular Architecture**: Clean separation of concerns
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
KeywordAnalysis,
|
||||
ReadabilityAnalysis,
|
||||
StructureAnalysis,
|
||||
Recommendations,
|
||||
SEOProcessor
|
||||
} from './SEO';
|
||||
|
||||
// In your component
|
||||
<KeywordAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
<ReadabilityAnalysis
|
||||
detailedAnalysis={analysisResult.detailed_analysis}
|
||||
visualizationData={analysisResult.visualization_data}
|
||||
/>
|
||||
<StructureAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
<Recommendations recommendations={analysisResult.actionable_recommendations} />
|
||||
<SEOProcessor
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
seoMetadata={seoMetadata}
|
||||
onSEOAnalysis={onSEOAnalysis}
|
||||
onSEOMetadata={onSEOMetadata}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### KeywordAnalysis Props
|
||||
- `detailedAnalysis?: { keyword_analysis?: {...} }` - Detailed analysis data from backend
|
||||
|
||||
### ReadabilityAnalysis Props
|
||||
- `detailedAnalysis?: { readability_analysis?: {...}, content_quality?: {...}, content_structure?: {...} }` - Detailed analysis data
|
||||
- `visualizationData?: { content_stats?: {...} }` - Visualization data for fallback values
|
||||
|
||||
### StructureAnalysis Props
|
||||
- `detailedAnalysis?: { content_structure?: {...}, heading_structure?: {...} }` - Detailed analysis data
|
||||
|
||||
### Recommendations Props
|
||||
- `recommendations: Recommendation[]` - Array of actionable recommendations with priority and impact
|
||||
|
||||
### SEOProcessor Props
|
||||
- `buildFullMarkdown: () => string` - Function to build full markdown content
|
||||
- `seoMetadata: BlogSEOMetadataResponse | null` - Current SEO metadata
|
||||
- `onSEOAnalysis: (analysis: any) => void` - Callback for SEO analysis results
|
||||
- `onSEOMetadata: (metadata: BlogSEOMetadataResponse) => void` - Callback for SEO metadata results
|
||||
286
frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
Normal file
286
frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Readability Analysis Component
|
||||
*
|
||||
* Displays comprehensive readability analysis including readability metrics,
|
||||
* content statistics, sentence/paragraph analysis, and target audience information.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
MenuBook
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ReadabilityAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
readability_analysis?: {
|
||||
metrics: Record<string, number>;
|
||||
avg_sentence_length: number;
|
||||
avg_paragraph_length: number;
|
||||
readability_score: number;
|
||||
target_audience: string;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_quality?: {
|
||||
word_count: number;
|
||||
unique_words: number;
|
||||
vocabulary_diversity: number;
|
||||
transition_words_used: number;
|
||||
content_depth_score: number;
|
||||
flow_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
visualizationData?: {
|
||||
content_stats: {
|
||||
word_count: number;
|
||||
sections: number;
|
||||
paragraphs: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<MenuBook sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Readability Metrics
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Measures how easy your content is to read and understand.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Average Sentence Length:</strong> 15-20 words is optimal
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<MenuBook />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
|
||||
const getReadabilityTooltip = (metric: string, value: number) => {
|
||||
const tooltips = {
|
||||
flesch_reading_ease: {
|
||||
description: "Measures how easy text is to read (0-100 scale)",
|
||||
interpretation: value >= 80 ? "Very Easy" : value >= 60 ? "Standard" : "Difficult"
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: "U.S. grade level needed to understand the text",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
gunning_fog: {
|
||||
description: "Years of formal education needed to understand the text",
|
||||
interpretation: value <= 12 ? "Easy" : value <= 16 ? "Moderate" : "Difficult"
|
||||
},
|
||||
smog_index: {
|
||||
description: "Simple Measure of Gobbledygook - readability formula",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
automated_readability: {
|
||||
description: "Automated Readability Index based on character count",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
coleman_liau: {
|
||||
description: "Readability test based on average sentence length and characters per word",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
}
|
||||
};
|
||||
return tooltips[metric as keyof typeof tooltips] || { description: "Readability metric", interpretation: "N/A" };
|
||||
};
|
||||
|
||||
const tooltip = getReadabilityTooltip(metric, value);
|
||||
return (
|
||||
<Tooltip
|
||||
key={metric}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
<strong>Interpretation:</strong> {tooltip.interpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
||||
{metric.replace('_', ' ')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{value.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
No readability metrics available. This may indicate an issue with the content analysis.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Content Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Word Count</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Unique Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Vocabulary Diversity</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Readability Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Sentence & Paragraph Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Sentence Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Paragraph Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Transition Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Reading Level</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Content Depth Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Flow Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
108
frontend/src/components/BlogWriter/SEO/Recommendations.tsx
Normal file
108
frontend/src/components/BlogWriter/SEO/Recommendations.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Recommendations Component
|
||||
*
|
||||
* Displays actionable SEO recommendations with priority indicators,
|
||||
* category tags, and impact descriptions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Recommendation {
|
||||
category: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
interface RecommendationsProps {
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'error.main';
|
||||
case 'Medium': return 'warning.main';
|
||||
case 'Low': return 'success.main';
|
||||
default: return 'text.secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
|
||||
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
|
||||
default: return <Warning sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Lightbulb sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Actionable Recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recommendations.map((rec, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface SEOProcessorProps {
|
||||
buildFullMarkdown: () => string;
|
||||
@@ -17,22 +17,7 @@ export const SEOProcessor: React.FC<SEOProcessorProps> = ({
|
||||
onSEOAnalysis,
|
||||
onSEOMetadata
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'runSEOAnalyze',
|
||||
description: 'Analyze SEO for the full draft',
|
||||
parameters: [ { name: 'keywords', type: 'string', description: 'Comma-separated keywords', required: false } ],
|
||||
handler: async ({ keywords }: { keywords?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoAnalyze({ content, keywords: keywords ? keywords.split(',').map(k => k.trim()) : [] });
|
||||
onSEOAnalysis(res);
|
||||
return { success: true, seo_score: res.seo_score };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div>SEO Score: {result?.seo_score ?? '—'}</div>
|
||||
</div>
|
||||
) : null
|
||||
});
|
||||
// Removed old runSEOAnalyze action - now using runComprehensiveSEOAnalysis in BlogWriter.tsx
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
@@ -63,7 +48,24 @@ export const SEOProcessor: React.FC<SEOProcessorProps> = ({
|
||||
handler: async ({ sectionId, goals }: { sectionId: string; goals?: string }) => {
|
||||
const current = buildFullMarkdown();
|
||||
if (!current) return { success: false, message: 'No content yet for this section' };
|
||||
const res = await blogWriterApi.seoAnalyze({ content: current, keywords: [] });
|
||||
|
||||
// Use comprehensive SEO analysis endpoint
|
||||
const response = await fetch('/api/blog-writer/seo/analyze', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: current,
|
||||
keywords: []
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to analyze blog content');
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
onSEOAnalysis(res);
|
||||
return { success: true, message: 'Analysis ready' };
|
||||
},
|
||||
196
frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
Normal file
196
frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Structure Analysis Component
|
||||
*
|
||||
* Displays comprehensive content structure analysis including structure overview,
|
||||
* content elements detection, and heading structure analysis.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
BarChart
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface StructureAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
heading_structure?: {
|
||||
h1_count: number;
|
||||
h2_count: number;
|
||||
h3_count: number;
|
||||
h1_headings: string[];
|
||||
h2_headings: string[];
|
||||
h3_headings: string[];
|
||||
heading_hierarchy_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
// Debug logging
|
||||
console.log('🏗️ StructureAnalysis received data:', detailedAnalysis);
|
||||
console.log('📊 Content Structure:', detailedAnalysis?.content_structure);
|
||||
console.log('📋 Heading Structure:', detailedAnalysis?.heading_structure);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Content Structure Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
{/* Content Structure Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Structure Overview
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Total Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Structure Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Elements */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Content Elements
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Has Introduction</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Heading Structure */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Heading Structure Analysis
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
11
frontend/src/components/BlogWriter/SEO/index.ts
Normal file
11
frontend/src/components/BlogWriter/SEO/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* SEO Components Index
|
||||
*
|
||||
* Exports all SEO-related components for easy importing.
|
||||
*/
|
||||
|
||||
export { KeywordAnalysis } from './KeywordAnalysis';
|
||||
export { ReadabilityAnalysis } from './ReadabilityAnalysis';
|
||||
export { StructureAnalysis } from './StructureAnalysis';
|
||||
export { Recommendations } from './Recommendations';
|
||||
export { SEOProcessor } from './SEOProcessor';
|
||||
710
frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
Normal file
710
frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* SEO Analysis Modal Component
|
||||
*
|
||||
* Displays comprehensive SEO analysis results with visual charts and actionable recommendations.
|
||||
* Integrates with CopilotKit for real-time progress updates and user interactions.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
Grid,
|
||||
Paper,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Warning,
|
||||
TrendingUp,
|
||||
GpsFixed,
|
||||
MenuBook,
|
||||
Search,
|
||||
BarChart,
|
||||
Lightbulb,
|
||||
Refresh,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
||||
|
||||
interface SEOAnalysisResult {
|
||||
overall_score: number;
|
||||
category_scores: {
|
||||
structure: number;
|
||||
keywords: number;
|
||||
readability: number;
|
||||
quality: number;
|
||||
headings: number;
|
||||
ai_insights: number;
|
||||
};
|
||||
analysis_summary: {
|
||||
overall_grade: string;
|
||||
status: string;
|
||||
strongest_category: string;
|
||||
weakest_category: string;
|
||||
key_strengths: string[];
|
||||
key_weaknesses: string[];
|
||||
ai_summary: string;
|
||||
};
|
||||
actionable_recommendations: Array<{
|
||||
category: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}>;
|
||||
visualization_data: {
|
||||
score_radar: {
|
||||
categories: string[];
|
||||
scores: number[];
|
||||
max_score: number;
|
||||
};
|
||||
keyword_analysis: {
|
||||
densities: Record<string, number>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
};
|
||||
readability_metrics: Record<string, number>;
|
||||
content_stats: {
|
||||
word_count: number;
|
||||
sections: number;
|
||||
paragraphs: number;
|
||||
};
|
||||
};
|
||||
detailed_analysis?: {
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
keyword_analysis?: {
|
||||
primary_keywords: string[];
|
||||
long_tail_keywords: string[];
|
||||
semantic_keywords: string[];
|
||||
keyword_density: Record<string, number>;
|
||||
keyword_distribution: Record<string, any>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
readability_analysis?: {
|
||||
metrics: Record<string, number>;
|
||||
avg_sentence_length: number;
|
||||
avg_paragraph_length: number;
|
||||
readability_score: number;
|
||||
target_audience: string;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_quality?: {
|
||||
word_count: number;
|
||||
unique_words: number;
|
||||
vocabulary_diversity: number;
|
||||
transition_words_used: number;
|
||||
content_depth_score: number;
|
||||
flow_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
heading_structure?: {
|
||||
h1_count: number;
|
||||
h2_count: number;
|
||||
h3_count: number;
|
||||
h1_headings: string[];
|
||||
h2_headings: string[];
|
||||
h3_headings: string[];
|
||||
heading_hierarchy_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
interface SEOAnalysisModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
blogContent: string;
|
||||
researchData: any;
|
||||
onApplyRecommendations?: (recommendations: any[]) => void;
|
||||
}
|
||||
|
||||
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
blogContent,
|
||||
researchData,
|
||||
onApplyRecommendations
|
||||
}) => {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('recommendations');
|
||||
|
||||
// Debug logging
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
|
||||
const runSEOAnalysis = async () => {
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulate progress updates (in real implementation, this would be SSE)
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
||||
{ progress: 70, message: 'Generating AI-powered insights...' },
|
||||
{ progress: 90, message: 'Compiling analysis results...' },
|
||||
{ progress: 100, message: 'SEO analysis completed!' }
|
||||
];
|
||||
|
||||
for (const stage of progressStages) {
|
||||
setProgress(stage.progress);
|
||||
setProgressMessage(stage.message);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Make API call to analyze blog content
|
||||
const response = await fetch('/api/blog-writer/seo/analyze', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
blog_content: blogContent,
|
||||
research_data: researchData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to analyze blog content');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||
console.log('📊 Category Scores:', result.category_scores);
|
||||
console.log('💡 Recommendations:', result.actionable_recommendations);
|
||||
console.log('🔍 Visualization Data:', result.visualization_data);
|
||||
console.log('📝 Detailed Analysis:', result.detailed_analysis);
|
||||
console.log('🏗️ Content Structure:', result.detailed_analysis?.content_structure);
|
||||
console.log('📋 Heading Structure:', result.detailed_analysis?.heading_structure);
|
||||
|
||||
// Convert API response to frontend format - fail fast if data is missing
|
||||
if (!result.success) {
|
||||
throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
}
|
||||
|
||||
if (!result.overall_score && result.overall_score !== 0) {
|
||||
throw new Error('Invalid SEO score received from API');
|
||||
}
|
||||
|
||||
const convertedResult: SEOAnalysisResult = {
|
||||
overall_score: result.overall_score,
|
||||
category_scores: {
|
||||
structure: result.category_scores?.structure || 0,
|
||||
keywords: result.category_scores?.keywords || 0,
|
||||
readability: result.category_scores?.readability || 0,
|
||||
quality: result.category_scores?.quality || 0,
|
||||
headings: result.category_scores?.headings || 0,
|
||||
ai_insights: result.category_scores?.ai_insights || 0
|
||||
},
|
||||
analysis_summary: result.analysis_summary || {
|
||||
overall_grade: result.overall_score >= 80 ? 'A' : result.overall_score >= 60 ? 'B' : 'C',
|
||||
status: result.overall_score >= 80 ? 'Excellent' : result.overall_score >= 60 ? 'Good' : 'Needs Improvement',
|
||||
strongest_category: 'structure',
|
||||
weakest_category: 'keywords',
|
||||
key_strengths: ['Good content structure', 'Appropriate length'],
|
||||
key_weaknesses: ['Keyword optimization needs work'],
|
||||
ai_summary: 'Content provides good value with room for SEO improvements.'
|
||||
},
|
||||
actionable_recommendations: (result.actionable_recommendations || []).map((rec: any) => ({
|
||||
category: rec.category || 'General',
|
||||
priority: rec.priority || 'Medium' as const,
|
||||
recommendation: rec.recommendation || rec,
|
||||
impact: rec.impact || 'Improves SEO performance'
|
||||
})),
|
||||
visualization_data: {
|
||||
score_radar: {
|
||||
categories: ['structure', 'keywords', 'readability', 'quality', 'headings', 'ai_insights'],
|
||||
scores: [
|
||||
result.category_scores?.structure || 0,
|
||||
result.category_scores?.keywords || 0,
|
||||
result.category_scores?.readability || 0,
|
||||
result.category_scores?.quality || 0,
|
||||
result.category_scores?.headings || 0,
|
||||
result.category_scores?.ai_insights || 0
|
||||
],
|
||||
max_score: 100
|
||||
},
|
||||
keyword_analysis: {
|
||||
densities: result.visualization_data?.keyword_analysis?.densities || {},
|
||||
missing_keywords: result.visualization_data?.keyword_analysis?.missing_keywords || [],
|
||||
over_optimization: result.visualization_data?.keyword_analysis?.over_optimization || []
|
||||
},
|
||||
readability_metrics: result.visualization_data?.readability_metrics || {},
|
||||
content_stats: {
|
||||
word_count: result.visualization_data?.content_stats?.word_count || 0,
|
||||
sections: result.visualization_data?.content_stats?.sections || 0,
|
||||
paragraphs: result.visualization_data?.content_stats?.paragraphs || 0
|
||||
}
|
||||
},
|
||||
detailed_analysis: result.detailed_analysis || undefined,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
setAnalysisResult(convertedResult);
|
||||
setIsAnalyzing(false);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'success.main';
|
||||
if (score >= 60) return 'warning.main';
|
||||
return 'error.main';
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'error.main';
|
||||
case 'Medium': return 'warning.main';
|
||||
case 'Low': return 'success.main';
|
||||
default: return 'text.secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
|
||||
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
|
||||
default: return <Warning sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip content for each metric
|
||||
const getMetricTooltip = (category: string) => {
|
||||
const tooltips = {
|
||||
structure: {
|
||||
title: "Content Structure Analysis",
|
||||
description: "Evaluates how well your content is organized and structured for both readers and search engines.",
|
||||
methodology: "Analyzes heading hierarchy (H1, H2, H3), paragraph length, section organization, and logical flow.",
|
||||
score_meaning: "Higher scores indicate better content organization, clear headings, and logical structure.",
|
||||
examples: "Good: Clear H1 title, logical H2 sections, short paragraphs. Poor: No headings, long walls of text."
|
||||
},
|
||||
keywords: {
|
||||
title: "Keyword Optimization Analysis",
|
||||
description: "Measures how effectively your target keywords are used throughout the content.",
|
||||
methodology: "Analyzes keyword density, distribution, placement in headings, and semantic keyword usage.",
|
||||
score_meaning: "Higher scores indicate optimal keyword usage without over-optimization.",
|
||||
examples: "Good: 1-3% keyword density, keywords in headings. Poor: Keyword stuffing or missing target keywords."
|
||||
},
|
||||
readability: {
|
||||
title: "Readability Assessment",
|
||||
description: "Evaluates how easy your content is to read and understand for your target audience.",
|
||||
methodology: "Uses Flesch Reading Ease, sentence length, word complexity, and paragraph structure.",
|
||||
score_meaning: "Higher scores indicate content that's easier to read and understand.",
|
||||
examples: "Good: Short sentences, simple words, clear paragraphs. Poor: Long complex sentences, jargon."
|
||||
},
|
||||
quality: {
|
||||
title: "Content Quality Evaluation",
|
||||
description: "Assesses the depth, value, and comprehensiveness of your content.",
|
||||
methodology: "Analyzes word count, content depth, information density, and topic coverage.",
|
||||
score_meaning: "Higher scores indicate more comprehensive and valuable content.",
|
||||
examples: "Good: Detailed explanations, examples, comprehensive coverage. Poor: Thin content, lack of detail."
|
||||
},
|
||||
headings: {
|
||||
title: "Heading Structure Analysis",
|
||||
description: "Evaluates the effectiveness of your heading hierarchy and organization.",
|
||||
methodology: "Analyzes heading distribution, hierarchy levels, keyword usage in headings, and logical flow.",
|
||||
score_meaning: "Higher scores indicate better heading structure and organization.",
|
||||
examples: "Good: Clear H1, logical H2/H3 progression. Poor: Missing headings, poor hierarchy."
|
||||
},
|
||||
ai_insights: {
|
||||
title: "AI-Powered Content Insights",
|
||||
description: "Advanced analysis of content engagement potential and user value.",
|
||||
methodology: "Uses AI to analyze content quality, engagement factors, and user value proposition.",
|
||||
score_meaning: "Higher scores indicate content that's more likely to engage and provide value to readers.",
|
||||
examples: "Good: Clear value proposition, engaging content, actionable insights. Poor: Generic content, low engagement potential."
|
||||
}
|
||||
};
|
||||
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: '90vh',
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
color: 'text.primary'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Search sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
|
||||
SEO Analysis Results
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
Comprehensive analysis of your blog content's SEO optimization
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isAnalyzing && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Refresh sx={{
|
||||
fontSize: 32,
|
||||
animation: 'spin 1s linear infinite',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{progressMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #4caf50, #8bc34a)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||
<Cancel sx={{ mr: 1 }} />
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{analysisResult && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Overall Score Section */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Overall SEO Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(analysisResult.overall_score),
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{analysisResult.overall_score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{analysisResult.analysis_summary.overall_grade}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Grade
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Chip
|
||||
label={analysisResult.analysis_summary.status}
|
||||
color={getScoreBadgeVariant(analysisResult.overall_score)}
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Scores */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Category Breakdown
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(analysisResult.category_scores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Grid item xs={6} md={4} key={category}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 2,
|
||||
cursor: 'help',
|
||||
'&:hover': {
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
transform: 'translateY(-2px)',
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(score),
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Analysis Tabs */}
|
||||
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(e, newValue) => setTabValue(newValue)}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.main',
|
||||
fontWeight: 600
|
||||
}
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
background: 'linear-gradient(90deg, #4caf50, #8bc34a)',
|
||||
height: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab label="Recommendations" value="recommendations" />
|
||||
<Tab label="Keywords" value="keywords" />
|
||||
<Tab label="Readability" value="readability" />
|
||||
<Tab label="Structure" value="structure" />
|
||||
<Tab label="AI Insights" value="insights" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
{tabValue === 'recommendations' && (
|
||||
<Recommendations recommendations={analysisResult.actionable_recommendations} />
|
||||
)}
|
||||
|
||||
{tabValue === 'keywords' && (
|
||||
<KeywordAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
)}
|
||||
|
||||
{tabValue === 'readability' && (
|
||||
<ReadabilityAnalysis
|
||||
detailedAnalysis={analysisResult.detailed_analysis}
|
||||
visualizationData={analysisResult.visualization_data}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === 'structure' && (
|
||||
<StructureAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
)}
|
||||
|
||||
{tabValue === 'insights' && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
AI-Powered Insights
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Content Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{analysisResult.analysis_summary.ai_summary}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Key Strengths
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{strength}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Areas for Improvement
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{weakness}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (onApplyRecommendations) {
|
||||
onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
disabled={!onApplyRecommendations}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #45a049, #7cb342)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Recommendations
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,9 @@ interface SuggestionsGeneratorProps {
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string };
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string };
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string };
|
||||
hasContent?: boolean;
|
||||
flowAnalysisCompleted?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
}
|
||||
|
||||
export const useSuggestions = (
|
||||
@@ -16,7 +19,10 @@ export const useSuggestions = (
|
||||
outlineConfirmed: boolean = false,
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string },
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string },
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string }
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string },
|
||||
hasContent: boolean = false,
|
||||
flowAnalysisCompleted: boolean = false,
|
||||
contentConfirmed: boolean = false
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
||||
@@ -82,7 +88,7 @@ export const useSuggestions = (
|
||||
// Outline created but not confirmed - focus on outline review and confirmation
|
||||
items.push({
|
||||
title: 'Next: Confirm & Generate Content',
|
||||
message: 'I\'m happy with the outline, let\'s generate content for all sections',
|
||||
message: 'I confirm the outline and am ready to generate content',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
@@ -98,17 +104,49 @@ export const useSuggestions = (
|
||||
message: 'Rebalance word count distribution across sections'
|
||||
});
|
||||
} else if (outline.length > 0 && outlineConfirmed) {
|
||||
// Outline confirmed, focus on content generation
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
|
||||
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
|
||||
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
||||
items.push({ title: '🚀 Publish to WordPress', message: 'Publish my blog to WordPress' });
|
||||
// Outline confirmed, focus on content generation and optimization
|
||||
if (hasContent && !contentConfirmed) {
|
||||
// User has content but hasn't confirmed it yet - show content review suggestions
|
||||
items.push({
|
||||
title: 'Next: Confirm Blog Content',
|
||||
message: 'I have reviewed and confirmed my blog content is ready for the next stage',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🔄 ReWrite Blog',
|
||||
message: 'I want to rewrite my blog with different approach, tone, or focus'
|
||||
});
|
||||
items.push({
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post'
|
||||
});
|
||||
} else if (hasContent && contentConfirmed) {
|
||||
// Content confirmed - move to SEO stage
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🧾 Generate SEO Metadata',
|
||||
message: 'Generate SEO metadata and title'
|
||||
});
|
||||
items.push({
|
||||
title: '🚀 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress'
|
||||
});
|
||||
} else {
|
||||
// No content yet, show generation option
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling]);
|
||||
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling, hasContent, flowAnalysisCompleted, contentConfirmed]);
|
||||
};
|
||||
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
|
||||
|
||||
@@ -28,6 +28,8 @@ interface BlogEditorProps {
|
||||
sections?: Record<string, string>;
|
||||
onContentUpdate?: (sections: any[]) => void;
|
||||
onSave?: (content: any) => void;
|
||||
continuityRefresh?: number;
|
||||
flowAnalysisResults?: any;
|
||||
}
|
||||
|
||||
const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
@@ -39,7 +41,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
aiGeneratedTitles = [],
|
||||
sections: parentSections,
|
||||
onContentUpdate,
|
||||
onSave
|
||||
onSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults
|
||||
}) => {
|
||||
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
@@ -146,6 +150,8 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
onContentUpdate={onContentUpdate}
|
||||
expandedSections={expandedSections}
|
||||
toggleSectionExpansion={toggleSectionExpansion}
|
||||
refreshToken={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
|
||||
import { ContinuityBadge } from '../ContinuityBadge';
|
||||
|
||||
interface BlogSectionProps {
|
||||
id: any;
|
||||
@@ -37,6 +38,8 @@ interface BlogSectionProps {
|
||||
onContentUpdate?: (sections: any[]) => void;
|
||||
expandedSections: Set<any>;
|
||||
toggleSectionExpansion: (sectionId: any) => void;
|
||||
refreshToken?: number;
|
||||
flowAnalysisResults?: any;
|
||||
}
|
||||
|
||||
const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
@@ -48,7 +51,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
outlineData,
|
||||
onContentUpdate,
|
||||
expandedSections,
|
||||
toggleSectionExpansion
|
||||
toggleSectionExpansion,
|
||||
refreshToken,
|
||||
flowAnalysisResults
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [sectionTitle, setSectionTitle] = useState(title);
|
||||
@@ -110,6 +115,7 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
|
||||
const handleContentChange = (e: any) => {
|
||||
const newContent = e.target.value;
|
||||
console.log('🔍 [BlogSection] handleContentChange called, content length:', newContent.length);
|
||||
setContent(newContent);
|
||||
|
||||
// Trigger smart typing assist
|
||||
@@ -147,24 +153,27 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={sectionTitle}
|
||||
onChange={(e) => setSectionTitle(e.target.value)}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
autoFocus
|
||||
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800 mb-4' }}
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 mb-4 cursor-pointer"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={sectionTitle}
|
||||
onChange={(e) => setSectionTitle(e.target.value)}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
autoFocus
|
||||
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800' }}
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 cursor-pointer"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative"
|
||||
@@ -359,6 +368,15 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
<AutoAwesomeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Flow Analysis Badge - Enabled when flow analysis results are available */}
|
||||
<ContinuityBadge
|
||||
sectionId={id}
|
||||
refreshToken={refreshToken}
|
||||
disabled={!flowAnalysisResults}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
|
||||
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
|
||||
import TextSelectionMenu from './TextSelectionMenu';
|
||||
import useSmartTypingAssist from './SmartTypingAssist';
|
||||
|
||||
interface BlogTextSelectionHandlerProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -17,11 +18,8 @@ const useBlogTextSelectionHandler = (
|
||||
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Smart typing assist states
|
||||
const [smartSuggestion, setSmartSuggestion] = useState<{ text: string; position: { x: number; y: number } } | null>(null);
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Use the extracted smart typing assist hook
|
||||
const smartTypingAssist = useSmartTypingAssist(contentRef, onTextReplace);
|
||||
|
||||
// Fact-checking functionality
|
||||
const handleCheckFacts = async (text: string) => {
|
||||
@@ -179,84 +177,6 @@ const useBlogTextSelectionHandler = (
|
||||
setSelectionMenu(null);
|
||||
};
|
||||
|
||||
// Smart typing assist functionality
|
||||
const generateSmartSuggestion = async (currentText: string) => {
|
||||
if (currentText.length < 20) return; // Only suggest after some meaningful content
|
||||
|
||||
setIsGeneratingSuggestion(true);
|
||||
|
||||
try {
|
||||
// Simulate AI generation with contextual suggestions
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const suggestions = [
|
||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
|
||||
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
|
||||
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
|
||||
"Moreover, this framework addresses common challenges while providing practical solutions."
|
||||
];
|
||||
|
||||
const randomSuggestion = suggestions[Math.floor(Math.random() * suggestions.length)];
|
||||
|
||||
// Get cursor position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
position: { x, y }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate smart suggestion:', error);
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypingChange = (newText: string) => {
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Clear any existing suggestion when user types
|
||||
setSmartSuggestion(null);
|
||||
|
||||
// Set new timeout for suggestion generation
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
// First time suggestion appears automatically
|
||||
if (!hasShownFirstSuggestion && newText.length > 20) {
|
||||
generateSmartSuggestion(newText);
|
||||
setHasShownFirstSuggestion(true);
|
||||
}
|
||||
// After first time, only suggest after longer pauses or more content
|
||||
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
|
||||
generateSmartSuggestion(newText);
|
||||
}
|
||||
}, 3000); // 3 second pause before suggesting
|
||||
};
|
||||
|
||||
const handleAcceptSuggestion = () => {
|
||||
if (smartSuggestion && onTextReplace && contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
|
||||
const newContent = currentContent + ' ' + smartSuggestion.text;
|
||||
|
||||
// Use the text replacement callback
|
||||
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
||||
|
||||
setSmartSuggestion(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectSuggestion = () => {
|
||||
setSmartSuggestion(null);
|
||||
};
|
||||
|
||||
// Cleanup progress and timeouts on unmount
|
||||
useEffect(() => {
|
||||
@@ -265,9 +185,6 @@ const useBlogTextSelectionHandler = (
|
||||
if (selectionTimeoutRef.current) {
|
||||
clearTimeout(selectionTimeoutRef.current);
|
||||
}
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -306,9 +223,29 @@ const useBlogTextSelectionHandler = (
|
||||
|
||||
console.log('🔍 [BlogTextSelectionHandler] Range rect:', rect);
|
||||
|
||||
// Check if rect has valid dimensions
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Invalid rect dimensions, trying alternative positioning');
|
||||
|
||||
// Try to get position from the textarea element itself
|
||||
if (contentRef.current) {
|
||||
const textareaRect = contentRef.current.getBoundingClientRect();
|
||||
console.log('🔍 [BlogTextSelectionHandler] Textarea rect:', textareaRect);
|
||||
|
||||
// Position menu near the textarea center
|
||||
const x = Math.max(8, Math.min(textareaRect.left + (textareaRect.width / 2), window.innerWidth - 280));
|
||||
const y = Math.max(8, textareaRect.top + window.scrollY - 60);
|
||||
|
||||
const menuPosition = { x, y, text };
|
||||
console.log('🔍 [BlogTextSelectionHandler] Using textarea position:', menuPosition);
|
||||
setSelectionMenu(menuPosition);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use viewport coordinates for absolute positioning
|
||||
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280)); // Account for menu width
|
||||
const y = Math.max(8, rect.top + window.scrollY);
|
||||
const y = Math.max(8, rect.top + window.scrollY - 60); // Position above selection
|
||||
|
||||
const menuPosition = { x, y, text };
|
||||
console.log('🔍 [BlogTextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
|
||||
@@ -327,464 +264,30 @@ const useBlogTextSelectionHandler = (
|
||||
factCheckResults,
|
||||
isFactChecking,
|
||||
factCheckProgress,
|
||||
smartSuggestion,
|
||||
isGeneratingSuggestion,
|
||||
handleTextSelection,
|
||||
handleCheckFacts,
|
||||
handleCloseFactCheckResults,
|
||||
handleQuickEdit,
|
||||
handleTypingChange,
|
||||
handleAcceptSuggestion,
|
||||
handleRejectSuggestion,
|
||||
// Smart typing assist functionality from extracted hook
|
||||
...smartTypingAssist,
|
||||
// Render the selection menu and fact-check components
|
||||
renderSelectionMenu: () => (
|
||||
<>
|
||||
{/* Text Selection Menu */}
|
||||
{selectionMenu && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Selection menu clicked!', e.target);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: selectionMenu.y - 60,
|
||||
left: Math.max(8, selectionMenu.x - 140),
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10000,
|
||||
minWidth: '240px',
|
||||
maxWidth: '280px'
|
||||
}}
|
||||
>
|
||||
{/* Fact Check Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Check Facts button clicked!', selectionMenu.text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCheckFacts(selectionMenu.text);
|
||||
}}
|
||||
disabled={isFactChecking}
|
||||
style={{
|
||||
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isFactChecking ? 'not-allowed' : 'pointer',
|
||||
opacity: isFactChecking ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFactChecking ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Fact-checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔍 Fact Check
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Quick Edit Options */}
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
paddingTop: '10px',
|
||||
marginTop: '6px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
✨ Assistive Writing
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('improve', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✏️ Improve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('add-transition', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🔗 Transition
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('shorten', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✂️ Shorten
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('expand', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📝 Expand
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('professionalize', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🎓 Professional
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('add-data', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📊 Add Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Progress */}
|
||||
{factCheckProgress && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '280px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Fact-checking content...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
{factCheckProgress.step}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Results */}
|
||||
{factCheckResults && (
|
||||
<FactCheckResults
|
||||
results={factCheckResults}
|
||||
onClose={handleCloseFactCheckResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Smart Typing Suggestion */}
|
||||
{smartSuggestion && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: smartSuggestion.position.y,
|
||||
left: smartSuggestion.position.x,
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10002,
|
||||
maxWidth: '400px',
|
||||
minWidth: '320px',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
opacity: 0.9,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
✨ Smart Writing Suggestion
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '16px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{smartSuggestion.text}"
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleRejectSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✕ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✓ Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestion Loading Indicator */}
|
||||
{isGeneratingSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '240px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Generating suggestion...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
AI is crafting helpful content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
<TextSelectionMenu
|
||||
selectionMenu={selectionMenu}
|
||||
factCheckResults={factCheckResults}
|
||||
isFactChecking={isFactChecking}
|
||||
factCheckProgress={factCheckProgress}
|
||||
smartSuggestion={smartTypingAssist.smartSuggestion}
|
||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||
allSuggestions={smartTypingAssist.allSuggestions}
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||
/>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
297
frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx
Normal file
297
frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SmartTypingAssistProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
text: string;
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const useSmartTypingAssist = (
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
|
||||
onTextReplace?: (originalText: string, newText: string, editType: string) => void
|
||||
) => {
|
||||
// Smart typing assist states
|
||||
const [smartSuggestion, setSmartSuggestion] = useState<{
|
||||
text: string;
|
||||
position: { x: number; y: number };
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
||||
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Quality improvement tracking
|
||||
const [suggestionStats, setSuggestionStats] = useState({
|
||||
totalShown: 0,
|
||||
totalAccepted: 0,
|
||||
totalRejected: 0,
|
||||
totalCycled: 0
|
||||
});
|
||||
|
||||
// Smart typing assist functionality
|
||||
const generateSmartSuggestion = async (currentText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] generateSmartSuggestion called with text length:', currentText.length);
|
||||
|
||||
if (currentText.length < 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Text too short for suggestion');
|
||||
return; // Only suggest after some meaningful content
|
||||
}
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
|
||||
setIsGeneratingSuggestion(true);
|
||||
|
||||
try {
|
||||
// Import the assistive writing API
|
||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Calling assistive writing API...');
|
||||
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
|
||||
|
||||
if (response.success && response.suggestions.length > 0) {
|
||||
console.log('🔍 [SmartTypingAssist] Received', response.suggestions.length, 'suggestions from API');
|
||||
|
||||
// Store all suggestions
|
||||
setAllSuggestions(response.suggestions);
|
||||
setSuggestionIndex(0);
|
||||
|
||||
// Show first suggestion
|
||||
const firstSuggestion = response.suggestions[0];
|
||||
console.log('🔍 [SmartTypingAssist] Showing first suggestion:', firstSuggestion.text.substring(0, 50) + '...');
|
||||
|
||||
// Track suggestion shown
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalShown: prev.totalShown + 1
|
||||
}));
|
||||
|
||||
// Get cursor position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
|
||||
const y = Math.max(20, rect.bottom + 10);
|
||||
|
||||
setSmartSuggestion({
|
||||
text: firstSuggestion.text,
|
||||
position: { x, y },
|
||||
confidence: firstSuggestion.confidence,
|
||||
sources: firstSuggestion.sources
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
|
||||
// Fallback to generic suggestions if API fails
|
||||
const fallbackSuggestions = [
|
||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
|
||||
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
|
||||
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
|
||||
"Moreover, this framework addresses common challenges while providing practical solutions."
|
||||
];
|
||||
|
||||
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
|
||||
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
position: { x, y }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔍 [SmartTypingAssist] Failed to generate smart suggestion:', error);
|
||||
|
||||
// Fallback to generic suggestions on error
|
||||
const fallbackSuggestions = [
|
||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
|
||||
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
|
||||
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
|
||||
"Moreover, this framework addresses common challenges while providing practical solutions."
|
||||
];
|
||||
|
||||
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
|
||||
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
position: { x, y }
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypingChange = (newText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] handleTypingChange called with text length:', newText.length);
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Clear any existing suggestion when user types
|
||||
setSmartSuggestion(null);
|
||||
|
||||
// Set new timeout for suggestion generation
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
console.log('🔍 [SmartTypingAssist] Typing timeout triggered, text length:', newText.length, 'hasShownFirstSuggestion:', hasShownFirstSuggestion);
|
||||
|
||||
// First time suggestion appears automatically
|
||||
if (!hasShownFirstSuggestion && newText.length > 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
setHasShownFirstSuggestion(true);
|
||||
}
|
||||
// After first time, only suggest after longer pauses or more content
|
||||
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
|
||||
}
|
||||
}, 3000); // 3 second pause before suggesting
|
||||
};
|
||||
|
||||
const handleAcceptSuggestion = () => {
|
||||
if (smartSuggestion && onTextReplace && contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
|
||||
const newContent = currentContent + ' ' + smartSuggestion.text;
|
||||
|
||||
// Track suggestion accepted
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalAccepted: prev.totalAccepted + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
|
||||
...suggestionStats,
|
||||
totalAccepted: suggestionStats.totalAccepted + 1
|
||||
});
|
||||
|
||||
// Use the text replacement callback
|
||||
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
||||
|
||||
setSmartSuggestion(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectSuggestion = () => {
|
||||
// Track suggestion rejected
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalRejected: prev.totalRejected + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
|
||||
...suggestionStats,
|
||||
totalRejected: suggestionStats.totalRejected + 1
|
||||
});
|
||||
|
||||
setSmartSuggestion(null);
|
||||
setAllSuggestions([]);
|
||||
setSuggestionIndex(0);
|
||||
};
|
||||
|
||||
const handleNextSuggestion = () => {
|
||||
if (allSuggestions.length > 0 && suggestionIndex < allSuggestions.length - 1) {
|
||||
const nextIndex = suggestionIndex + 1;
|
||||
const nextSuggestion = allSuggestions[nextIndex];
|
||||
|
||||
// Track suggestion cycled
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalCycled: prev.totalCycled + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
|
||||
...suggestionStats,
|
||||
totalCycled: suggestionStats.totalCycled + 1
|
||||
});
|
||||
|
||||
setSuggestionIndex(nextIndex);
|
||||
setSmartSuggestion(prev => prev ? {
|
||||
...prev,
|
||||
text: nextSuggestion.text,
|
||||
confidence: nextSuggestion.confidence,
|
||||
sources: nextSuggestion.sources
|
||||
} : null);
|
||||
}
|
||||
};
|
||||
|
||||
// Get suggestion statistics for quality improvement
|
||||
const getSuggestionStats = () => {
|
||||
const acceptanceRate = suggestionStats.totalShown > 0
|
||||
? Math.round((suggestionStats.totalAccepted / suggestionStats.totalShown) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...suggestionStats,
|
||||
acceptanceRate,
|
||||
engagementRate: suggestionStats.totalShown > 0
|
||||
? Math.round(((suggestionStats.totalAccepted + suggestionStats.totalCycled) / suggestionStats.totalShown) * 100)
|
||||
: 0
|
||||
};
|
||||
};
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
smartSuggestion,
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
suggestionStats: getSuggestionStats(),
|
||||
handleTypingChange,
|
||||
handleAcceptSuggestion,
|
||||
handleRejectSuggestion,
|
||||
handleNextSuggestion,
|
||||
getSuggestionStats,
|
||||
generateSmartSuggestion
|
||||
};
|
||||
};
|
||||
|
||||
export default useSmartTypingAssist;
|
||||
export type { SmartTypingAssistProps, Suggestion };
|
||||
554
frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx
Normal file
554
frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import React from 'react';
|
||||
import { HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
selectionMenu: { x: number; y: number; text: string } | null;
|
||||
factCheckResults: HallucinationDetectionResponse | null;
|
||||
isFactChecking: boolean;
|
||||
factCheckProgress: { step: string; progress: number } | null;
|
||||
smartSuggestion: {
|
||||
text: string;
|
||||
position: { x: number; y: number };
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
} | null;
|
||||
isGeneratingSuggestion: boolean;
|
||||
allSuggestions: Array<{
|
||||
text: string;
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
}>;
|
||||
suggestionIndex: number;
|
||||
onCheckFacts: (text: string) => void;
|
||||
onCloseFactCheckResults: () => void;
|
||||
onQuickEdit: (editType: string, selectedText: string) => void;
|
||||
onAcceptSuggestion: () => void;
|
||||
onRejectSuggestion: () => void;
|
||||
onNextSuggestion: () => void;
|
||||
}
|
||||
|
||||
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
selectionMenu,
|
||||
factCheckResults,
|
||||
isFactChecking,
|
||||
factCheckProgress,
|
||||
smartSuggestion,
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
onCheckFacts,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
onNextSuggestion
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Text Selection Menu */}
|
||||
{selectionMenu && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: selectionMenu.y - 60,
|
||||
left: Math.max(8, selectionMenu.x - 140),
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10000,
|
||||
minWidth: '240px',
|
||||
maxWidth: '280px'
|
||||
}}
|
||||
>
|
||||
{/* Fact Check Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Check Facts button clicked!', selectionMenu.text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCheckFacts(selectionMenu.text);
|
||||
}}
|
||||
disabled={isFactChecking}
|
||||
style={{
|
||||
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isFactChecking ? 'not-allowed' : 'pointer',
|
||||
opacity: isFactChecking ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFactChecking ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Fact-checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔍 Fact Check
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Quick Edit Options */}
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
paddingTop: '10px',
|
||||
marginTop: '6px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
✨ Assistive Writing
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('improve', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✏️ Improve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('add-transition', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🔗 Transition
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('shorten', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✂️ Shorten
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('expand', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📝 Expand
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('professionalize', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🎓 Professional
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('add-data', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📊 Add Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Progress */}
|
||||
{factCheckProgress && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '280px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Fact-checking content...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
{factCheckProgress.step}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Results */}
|
||||
{factCheckResults && (
|
||||
<FactCheckResults
|
||||
results={factCheckResults}
|
||||
onClose={onCloseFactCheckResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Smart Typing Suggestion */}
|
||||
{smartSuggestion && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Smart suggestion modal clicked!', smartSuggestion);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: smartSuggestion.position.y,
|
||||
left: smartSuggestion.position.x,
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10002,
|
||||
maxWidth: '400px',
|
||||
minWidth: '320px',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
opacity: 0.9,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>✨ Smart Writing Suggestion</span>
|
||||
{allSuggestions.length > 1 && (
|
||||
<span style={{ fontSize: '10px', opacity: 0.7 }}>
|
||||
{suggestionIndex + 1} of {allSuggestions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '16px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{smartSuggestion.text}"
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{allSuggestions.length > 1 && suggestionIndex < allSuggestions.length - 1 && (
|
||||
<button
|
||||
onClick={onNextSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
}}
|
||||
>
|
||||
↻ Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onRejectSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✕ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onAcceptSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✓ Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestion Loading Indicator */}
|
||||
{isGeneratingSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '240px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Generating suggestion...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
AI is crafting helpful content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextSelectionMenu;
|
||||
@@ -14,6 +14,8 @@ export const useBlogWriterState = () => {
|
||||
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(null);
|
||||
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
|
||||
const [outlineTaskId, setOutlineTaskId] = useState<string | null>(null);
|
||||
const [flowAnalysisCompleted, setFlowAnalysisCompleted] = useState<boolean>(false);
|
||||
const [flowAnalysisResults, setFlowAnalysisResults] = useState<any>(null);
|
||||
|
||||
// Enhanced metadata state
|
||||
const [sourceMappingStats, setSourceMappingStats] = useState<SourceMappingStats | null>(null);
|
||||
@@ -27,6 +29,9 @@ export const useBlogWriterState = () => {
|
||||
|
||||
// Outline confirmation state
|
||||
const [outlineConfirmed, setOutlineConfirmed] = useState<boolean>(false);
|
||||
|
||||
// Content confirmation state
|
||||
const [contentConfirmed, setContentConfirmed] = useState<boolean>(false);
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
@@ -203,6 +208,9 @@ export const useBlogWriterState = () => {
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
|
||||
// Setters
|
||||
setResearch,
|
||||
@@ -222,6 +230,9 @@ export const useBlogWriterState = () => {
|
||||
setResearchTitles,
|
||||
setAiGeneratedTitles,
|
||||
setOutlineConfirmed,
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
|
||||
// Handlers
|
||||
handleResearchComplete,
|
||||
|
||||
@@ -166,3 +166,12 @@ export function useMediumGenerationPolling(options: UsePollingOptions = {}) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return usePolling(wrapped, options);
|
||||
}
|
||||
|
||||
export function useRewritePolling(options: UsePollingOptions = {}) {
|
||||
// Lazy import to avoid circular: poll function from blogWriterApi
|
||||
const pollFn = (taskId: string) => import('../services/blogWriterApi').then(m => m.blogWriterApi.pollRewriteStatus(taskId));
|
||||
// Wrap to satisfy type
|
||||
const wrapped = (taskId: string) => pollFn(taskId) as unknown as Promise<TaskStatusResponse>;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return usePolling(wrapped, options);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,95 @@ export const blogWriterApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Blog Rewrite API
|
||||
async rewriteBlog(payload: {
|
||||
title: string;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
heading: string;
|
||||
content: string;
|
||||
}>;
|
||||
research: BlogResearchResponse;
|
||||
outline: BlogOutlineSection[];
|
||||
feedback: string;
|
||||
tone?: string;
|
||||
audience?: string;
|
||||
focus?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
taskId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const { data } = await aiApiClient.post('/api/blog/rewrite/start', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async pollRewriteStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||
const { data } = await pollingApiClient.get(`/api/blog/rewrite/status/${taskId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Flow Analysis APIs
|
||||
async analyzeFlowBasic(payload: {
|
||||
title: string;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
heading: string;
|
||||
content: string;
|
||||
}>;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
analysis?: {
|
||||
overall_flow_score: number;
|
||||
overall_consistency_score: number;
|
||||
overall_progression_score: number;
|
||||
sections: Array<{
|
||||
section_id: string;
|
||||
heading: string;
|
||||
flow_score: number;
|
||||
consistency_score: number;
|
||||
progression_score: number;
|
||||
suggestions: string[];
|
||||
}>;
|
||||
overall_suggestions: string[];
|
||||
};
|
||||
mode: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const { data } = await aiApiClient.post('/api/blog/flow-analysis/basic', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async analyzeFlowAdvanced(payload: {
|
||||
title: string;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
heading: string;
|
||||
content: string;
|
||||
}>;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
analysis?: {
|
||||
overall_flow_score: number;
|
||||
overall_consistency_score: number;
|
||||
overall_progression_score: number;
|
||||
sections: Array<{
|
||||
section_id: string;
|
||||
heading: string;
|
||||
flow_score: number;
|
||||
consistency_score: number;
|
||||
progression_score: number;
|
||||
detailed_analysis: string;
|
||||
suggestions: string[];
|
||||
}>;
|
||||
};
|
||||
mode: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const { data } = await aiApiClient.post('/api/blog/flow-analysis/advanced', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
async refineOutline(payload: { outline: BlogOutlineSection[]; operation: string; section_id?: string; payload?: any }): Promise<BlogOutlineResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/outline/refine", payload);
|
||||
@@ -246,10 +335,7 @@ export const blogWriterApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async seoAnalyze(payload: { content: string; keywords?: string[] }): Promise<BlogSEOAnalyzeResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/seo/analyze", payload);
|
||||
return data;
|
||||
},
|
||||
// Removed old seoAnalyze - now using comprehensive SEO analysis through modal
|
||||
|
||||
async seoMetadata(payload: { content: string; title?: string; keywords?: string[] }): Promise<BlogSEOMetadataResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/seo/metadata", payload);
|
||||
@@ -324,4 +410,33 @@ export const mediumBlogApi = {
|
||||
}
|
||||
};
|
||||
|
||||
// Assistive Writing API
|
||||
export interface AssistiveSuggestion {
|
||||
text: string;
|
||||
confidence: number;
|
||||
sources: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AssistiveSuggestionResponse {
|
||||
success: boolean;
|
||||
suggestions: AssistiveSuggestion[];
|
||||
}
|
||||
|
||||
export const assistiveWritingApi = {
|
||||
async getSuggestion(text: string, maxResults: number = 1): Promise<AssistiveSuggestionResponse> {
|
||||
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', {
|
||||
text,
|
||||
max_results: maxResults
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Services
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { monitoringService } from '../../services/monitoringService';
|
||||
|
||||
// Types
|
||||
import { DashboardData } from '../../types/billing';
|
||||
import { SystemHealth } from '../../types/monitoring';
|
||||
|
||||
// Components
|
||||
import BillingOverview from './BillingOverview';
|
||||
import SystemHealthIndicator from '../monitoring/SystemHealthIndicator';
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4 }
|
||||
}
|
||||
};
|
||||
|
||||
const BillingDashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// State management
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
// Fetch dashboard data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch billing and monitoring data in parallel
|
||||
const [billingData, healthData] = await Promise.all([
|
||||
billingService.getDashboardData(),
|
||||
monitoringService.getSystemHealth()
|
||||
]);
|
||||
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
console.error('Error fetching dashboard data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchDashboardData();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading && !dashboardData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '400px',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Loading billing dashboard...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !dashboardData) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<motion.button
|
||||
onClick={fetchDashboardData}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
Retry
|
||||
</motion.button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboardData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{/* Section Header */}
|
||||
<motion.div variants={cardVariants}>
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
💰 Billing & Usage Dashboard
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Monitor your API usage, costs, and system performance in real-time
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Dashboard Grid */}
|
||||
<Grid container spacing={3}>
|
||||
{/* Top Row - Overview Cards */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<BillingOverview
|
||||
usageStats={dashboardData.current_usage}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<SystemHealthIndicator
|
||||
systemHealth={systemHealth}
|
||||
onRefresh={fetchDashboardData}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Bottom Row - Detailed Metrics */}
|
||||
<Grid item xs={12}>
|
||||
<motion.div variants={cardVariants}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
📊 Detailed Usage Metrics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Usage Summary */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
|
||||
{dashboardData.current_usage.total_calls.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total API Calls
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
This month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Token Usage */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'secondary.main', fontWeight: 'bold' }}>
|
||||
{(dashboardData.current_usage.total_tokens / 1000).toFixed(1)}k
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tokens Used
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
This month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Average Response Time */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color: 'warning.main', fontWeight: 'bold' }}>
|
||||
{dashboardData.current_usage.avg_response_time.toFixed(0)}ms
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Avg Response Time
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 24 hours
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Error Rate */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: dashboardData.current_usage.error_rate > 5 ? 'error.main' : 'success.main',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{dashboardData.current_usage.error_rate.toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Error Rate
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last 24 hours
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingDashboard;
|
||||
@@ -1,270 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { UsageStats } from '../../types/billing';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
getUsageStatusColor,
|
||||
getUsageStatusIcon,
|
||||
calculateUsagePercentage
|
||||
} from '../../services/billingService';
|
||||
|
||||
interface BillingOverviewProps {
|
||||
usageStats: UsageStats;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
usageStats,
|
||||
onRefresh
|
||||
}) => {
|
||||
const costUsagePercentage = calculateUsagePercentage(
|
||||
usageStats.total_cost,
|
||||
usageStats.limits.limits.monthly_cost || 1
|
||||
);
|
||||
|
||||
const getStatusChip = () => {
|
||||
const status = usageStats.usage_status;
|
||||
const color = getUsageStatusColor(status);
|
||||
const icon = getUsageStatusIcon(status);
|
||||
|
||||
let chipColor: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
if (status === 'active') chipColor = 'success';
|
||||
else if (status === 'warning') chipColor = 'warning';
|
||||
else if (status === 'limit_reached') chipColor = 'error';
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={<span>{icon}</span>}
|
||||
label={status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ')}
|
||||
color={chipColor}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
|
||||
<DollarSign size={20} />
|
||||
Billing Overview
|
||||
</Typography>
|
||||
<Tooltip title="Refresh data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Status Chip */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{getStatusChip()}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{/* Current Cost */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{formatCurrency(usageStats.total_cost)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Cost This Month
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Usage Metrics */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
API Calls
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatNumber(usageStats.total_calls)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tokens Used
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatNumber(usageStats.total_tokens)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Avg Response Time
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{usageStats.avg_response_time.toFixed(0)}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Cost Usage Progress */}
|
||||
{usageStats.limits.limits.monthly_cost > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Monthly Cost Limit
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatPercentage(costUsagePercentage)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(costUsagePercentage, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: costUsagePercentage > 80 ? '#ef4444' :
|
||||
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e',
|
||||
borderRadius: 4,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{formatCurrency(usageStats.total_cost)} of {formatCurrency(usageStats.limits.limits.monthly_cost)} limit
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Plan Information */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Current Plan
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 1 }}>
|
||||
{usageStats.limits.plan_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{usageStats.limits.tier.charAt(0).toUpperCase() + usageStats.limits.tier.slice(1)} Tier
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-around' }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{usageStats.usage_percentages.gemini_calls.toFixed(0)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Gemini Usage
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'secondary.main' }}>
|
||||
{usageStats.error_rate.toFixed(1)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Error Rate
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
background: 'radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 60,
|
||||
height: 60,
|
||||
background: 'radial-gradient(circle, rgba(118, 75, 162, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingOverview;
|
||||
@@ -1,319 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
import { SystemHealth } from '../../types/monitoring';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
getHealthStatusColor,
|
||||
getHealthStatusIcon,
|
||||
formatErrorRate
|
||||
} from '../../services/monitoringService';
|
||||
|
||||
interface SystemHealthIndicatorProps {
|
||||
systemHealth: SystemHealth | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const SystemHealthIndicator: React.FC<SystemHealthIndicatorProps> = ({
|
||||
systemHealth,
|
||||
onRefresh
|
||||
}) => {
|
||||
if (!systemHealth) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Loading system health...</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const healthColor = getHealthStatusColor(systemHealth.status);
|
||||
const healthIcon = getHealthStatusIcon(systemHealth.status);
|
||||
|
||||
const getStatusChip = () => {
|
||||
let chipColor: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
if (systemHealth.status === 'healthy') chipColor = 'success';
|
||||
else if (systemHealth.status === 'warning') chipColor = 'warning';
|
||||
else if (systemHealth.status === 'critical') chipColor = 'error';
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={<span>{healthIcon}</span>}
|
||||
label={systemHealth.status.charAt(0).toUpperCase() + systemHealth.status.slice(1)}
|
||||
color={chipColor}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
|
||||
<Activity size={20} />
|
||||
System Health
|
||||
</Typography>
|
||||
<Tooltip title="Refresh data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Status Chip */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{getStatusChip()}
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{/* Main Health Indicator */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${healthColor}20 0%, ${healthColor}10 100%)`,
|
||||
border: `3px solid ${healthColor}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" sx={{ color: healthColor }}>
|
||||
{healthIcon}
|
||||
</Typography>
|
||||
|
||||
{/* Pulse animation for critical status */}
|
||||
{systemHealth.status === 'critical' && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${healthColor}`,
|
||||
opacity: 0.3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: healthColor }}>
|
||||
{systemHealth.status.charAt(0).toUpperCase() + systemHealth.status.slice(1)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
System Status
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Metrics */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Recent Requests
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{systemHealth.recent_requests.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Recent Errors
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
sx={{ color: systemHealth.recent_errors > 0 ? 'error.main' : 'text.primary' }}
|
||||
>
|
||||
{systemHealth.recent_errors}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Error Rate
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
sx={{ color: systemHealth.error_rate > 5 ? 'error.main' : 'text.primary' }}
|
||||
>
|
||||
{formatErrorRate(systemHealth.error_rate)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error Rate Progress */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Error Rate
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{formatErrorRate(systemHealth.error_rate)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(systemHealth.error_rate, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: systemHealth.error_rate > 10 ? '#ef4444' :
|
||||
systemHealth.error_rate > 5 ? '#f59e0b' : '#22c55e',
|
||||
borderRadius: 4,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{systemHealth.error_rate > 10 ? 'High error rate detected' :
|
||||
systemHealth.error_rate > 5 ? 'Moderate error rate' : 'Normal error rate'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Performance Indicators */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Performance Status
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<span style={{ color: healthColor }}>
|
||||
{healthIcon}
|
||||
</span>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{systemHealth.status.charAt(0).toUpperCase() + systemHealth.status.slice(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last updated: {new Date(systemHealth.timestamp).toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-around' }}>
|
||||
<Tooltip title="View detailed logs">
|
||||
<Box sx={{ textAlign: 'center', cursor: 'pointer' }}>
|
||||
<Clock size={20} color={healthColor} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Logs
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Performance metrics">
|
||||
<Box sx={{ textAlign: 'center', cursor: 'pointer' }}>
|
||||
<Zap size={20} color={healthColor} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Metrics
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
background: `radial-gradient(circle, ${healthColor}10 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 60,
|
||||
height: 60,
|
||||
background: `radial-gradient(circle, ${healthColor}05 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemHealthIndicator;
|
||||
@@ -1,272 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
DashboardData,
|
||||
UsageStats,
|
||||
UsageAlert,
|
||||
DashboardAPIResponse,
|
||||
UsageAPIResponse,
|
||||
AlertsAPIResponse,
|
||||
} from '../types/billing';
|
||||
|
||||
// API base configuration
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Create axios instance with default config
|
||||
const billingAPI = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/subscription`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for authentication
|
||||
billingAPI.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add auth token if available
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add user ID to requests
|
||||
const userId = localStorage.getItem('user_id') || 'demo-user';
|
||||
if (config.url?.includes('{user_id}')) {
|
||||
config.url = config.url.replace('{user_id}', userId);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
billingAPI.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Billing API Error:', error);
|
||||
|
||||
// Handle specific error cases
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
} else if (error.response?.status === 429) {
|
||||
// Rate limited
|
||||
console.warn('Rate limited by billing API');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Core billing service functions
|
||||
export const billingService = {
|
||||
/**
|
||||
* Get comprehensive dashboard data for a user
|
||||
*/
|
||||
getDashboardData: async (userId?: string): Promise<DashboardData> => {
|
||||
// For now, always return mock data since the API is not available
|
||||
console.log('Using mock data for billing dashboard');
|
||||
|
||||
// Return mock data for development
|
||||
return {
|
||||
current_usage: {
|
||||
billing_period: '2024-01',
|
||||
usage_status: 'active',
|
||||
total_calls: 1250,
|
||||
total_tokens: 45000,
|
||||
total_cost: 12.50,
|
||||
avg_response_time: 850,
|
||||
error_rate: 2.1,
|
||||
limits: {
|
||||
plan_name: 'Pro Plan',
|
||||
tier: 'pro',
|
||||
limits: {
|
||||
gemini_calls: 10000,
|
||||
openai_calls: 5000,
|
||||
anthropic_calls: 2000,
|
||||
mistral_calls: 1000,
|
||||
tavily_calls: 500,
|
||||
serper_calls: 200,
|
||||
metaphor_calls: 100,
|
||||
firecrawl_calls: 50,
|
||||
stability_calls: 25,
|
||||
gemini_tokens: 100000,
|
||||
openai_tokens: 50000,
|
||||
anthropic_tokens: 20000,
|
||||
mistral_tokens: 10000,
|
||||
monthly_cost: 100
|
||||
},
|
||||
features: ['Unlimited content generation', 'Priority support', 'Advanced analytics']
|
||||
},
|
||||
provider_breakdown: {
|
||||
gemini: { calls: 500, tokens: 20000, cost: 5.00 },
|
||||
openai: { calls: 300, tokens: 15000, cost: 4.50 },
|
||||
anthropic: { calls: 200, tokens: 8000, cost: 2.00 },
|
||||
mistral: { calls: 150, tokens: 2000, cost: 0.50 },
|
||||
tavily: { calls: 50, tokens: 0, cost: 0.25 },
|
||||
serper: { calls: 30, tokens: 0, cost: 0.15 },
|
||||
metaphor: { calls: 20, tokens: 0, cost: 0.10 },
|
||||
firecrawl: { calls: 0, tokens: 0, cost: 0 },
|
||||
stability: { calls: 0, tokens: 0, cost: 0 }
|
||||
},
|
||||
alerts: [],
|
||||
usage_percentages: {
|
||||
gemini_calls: 5,
|
||||
openai_calls: 6,
|
||||
anthropic_calls: 10,
|
||||
mistral_calls: 15,
|
||||
tavily_calls: 10,
|
||||
serper_calls: 15,
|
||||
metaphor_calls: 20,
|
||||
firecrawl_calls: 0,
|
||||
stability_calls: 0,
|
||||
cost: 12.5
|
||||
},
|
||||
last_updated: new Date().toISOString()
|
||||
},
|
||||
trends: {
|
||||
periods: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
total_calls: [800, 950, 1100, 1200, 1150, 1250],
|
||||
total_cost: [8.50, 10.20, 11.80, 12.10, 11.90, 12.50],
|
||||
total_tokens: [30000, 35000, 40000, 42000, 41000, 45000],
|
||||
provider_trends: {}
|
||||
},
|
||||
limits: {
|
||||
plan_name: 'Pro Plan',
|
||||
tier: 'pro',
|
||||
limits: {
|
||||
gemini_calls: 10000,
|
||||
openai_calls: 5000,
|
||||
anthropic_calls: 2000,
|
||||
mistral_calls: 1000,
|
||||
tavily_calls: 500,
|
||||
serper_calls: 200,
|
||||
metaphor_calls: 100,
|
||||
firecrawl_calls: 50,
|
||||
stability_calls: 25,
|
||||
gemini_tokens: 100000,
|
||||
openai_tokens: 50000,
|
||||
anthropic_tokens: 20000,
|
||||
mistral_tokens: 10000,
|
||||
monthly_cost: 100
|
||||
},
|
||||
features: ['Unlimited content generation', 'Priority support', 'Advanced analytics']
|
||||
},
|
||||
alerts: [],
|
||||
projections: {
|
||||
projected_monthly_cost: 15.20,
|
||||
cost_limit: 100,
|
||||
projected_usage_percentage: 15.2
|
||||
},
|
||||
summary: {
|
||||
total_api_calls_this_month: 1250,
|
||||
total_cost_this_month: 12.50,
|
||||
usage_status: 'active',
|
||||
unread_alerts: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark an alert as read
|
||||
*/
|
||||
markAlertRead: async (alertId: number): Promise<void> => {
|
||||
try {
|
||||
const response = await billingAPI.post(`/alerts/${alertId}/mark-read`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to mark alert as read');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking alert as read:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
export const formatPercentage = (value: number): string => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const getUsageStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '#22c55e'; // Green
|
||||
case 'warning':
|
||||
return '#f59e0b'; // Orange
|
||||
case 'limit_reached':
|
||||
return '#ef4444'; // Red
|
||||
default:
|
||||
return '#6b7280'; // Gray
|
||||
}
|
||||
};
|
||||
|
||||
export const getUsageStatusIcon = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '✅';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'limit_reached':
|
||||
return '🚨';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateUsagePercentage = (current: number, limit: number): number => {
|
||||
if (limit === 0) return 0;
|
||||
return Math.min((current / limit) * 100, 100);
|
||||
};
|
||||
|
||||
export const getProviderIcon = (provider: string): string => {
|
||||
const icons: { [key: string]: string } = {
|
||||
gemini: '🤖',
|
||||
openai: '🧠',
|
||||
anthropic: '🎭',
|
||||
mistral: '🌪️',
|
||||
tavily: '🔍',
|
||||
serper: '🔎',
|
||||
metaphor: '🔮',
|
||||
firecrawl: '🕷️',
|
||||
stability: '🎨',
|
||||
};
|
||||
return icons[provider.toLowerCase()] || '🔧';
|
||||
};
|
||||
|
||||
export const getProviderColor = (provider: string): string => {
|
||||
const colors: { [key: string]: string } = {
|
||||
gemini: '#4285f4',
|
||||
openai: '#10a37f',
|
||||
anthropic: '#d97706',
|
||||
mistral: '#7c3aed',
|
||||
tavily: '#059669',
|
||||
serper: '#dc2626',
|
||||
metaphor: '#7c2d12',
|
||||
firecrawl: '#ea580c',
|
||||
stability: '#0891b2',
|
||||
};
|
||||
return colors[provider.toLowerCase()] || '#6b7280';
|
||||
};
|
||||
|
||||
export default billingService;
|
||||
@@ -1,117 +0,0 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
SystemHealth,
|
||||
SystemHealthAPIResponse,
|
||||
} from '../types/monitoring';
|
||||
|
||||
// API base configuration
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Create axios instance for monitoring APIs
|
||||
const monitoringAPI = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/content-planning/monitoring`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for authentication
|
||||
monitoringAPI.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add auth token if available
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
monitoringAPI.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Monitoring API Error:', error);
|
||||
|
||||
// Handle specific error cases
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
} else if (error.response?.status === 503) {
|
||||
// Service unavailable
|
||||
console.warn('Monitoring service temporarily unavailable');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Core monitoring service functions
|
||||
export const monitoringService = {
|
||||
/**
|
||||
* Get system health status
|
||||
*/
|
||||
getSystemHealth: async (): Promise<SystemHealth> => {
|
||||
try {
|
||||
const response = await monitoringAPI.get<SystemHealthAPIResponse>('/health');
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to fetch system health');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching system health:', error);
|
||||
// Return default healthy state on error
|
||||
return {
|
||||
status: 'healthy',
|
||||
icon: '🟢',
|
||||
recent_requests: 1250,
|
||||
recent_errors: 26,
|
||||
error_rate: 2.1,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Utility functions for monitoring
|
||||
export const getHealthStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return '#22c55e'; // Green
|
||||
case 'warning':
|
||||
return '#f59e0b'; // Orange
|
||||
case 'critical':
|
||||
return '#ef4444'; // Red
|
||||
default:
|
||||
return '#6b7280'; // Gray
|
||||
}
|
||||
};
|
||||
|
||||
export const getHealthStatusIcon = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return '🟢';
|
||||
case 'warning':
|
||||
return '🟡';
|
||||
case 'critical':
|
||||
return '🔴';
|
||||
default:
|
||||
return '⚪';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatErrorRate = (rate: number): string => {
|
||||
return `${rate.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
export default monitoringService;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Core data structures for billing and usage tracking
|
||||
export interface DashboardData {
|
||||
current_usage: UsageStats;
|
||||
trends: UsageTrends;
|
||||
limits: SubscriptionLimits;
|
||||
alerts: UsageAlert[];
|
||||
projections: CostProjections;
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
billing_period: string;
|
||||
usage_status: 'active' | 'warning' | 'limit_reached';
|
||||
total_calls: number;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
avg_response_time: number;
|
||||
error_rate: number;
|
||||
limits: SubscriptionLimits;
|
||||
provider_breakdown: ProviderBreakdown;
|
||||
alerts: UsageAlert[];
|
||||
usage_percentages: UsagePercentages;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface ProviderBreakdown {
|
||||
gemini: ProviderUsage;
|
||||
openai: ProviderUsage;
|
||||
anthropic: ProviderUsage;
|
||||
mistral: ProviderUsage;
|
||||
tavily: ProviderUsage;
|
||||
serper: ProviderUsage;
|
||||
metaphor: ProviderUsage;
|
||||
firecrawl: ProviderUsage;
|
||||
stability: ProviderUsage;
|
||||
}
|
||||
|
||||
export interface ProviderUsage {
|
||||
calls: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionLimits {
|
||||
plan_name: string;
|
||||
tier: 'free' | 'basic' | 'pro' | 'enterprise';
|
||||
limits: {
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
mistral_calls: number;
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
gemini_tokens: number;
|
||||
openai_tokens: number;
|
||||
anthropic_tokens: number;
|
||||
mistral_tokens: number;
|
||||
monthly_cost: number;
|
||||
};
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface UsageTrends {
|
||||
periods: string[];
|
||||
total_calls: number[];
|
||||
total_cost: number[];
|
||||
total_tokens: number[];
|
||||
provider_trends: {
|
||||
[key: string]: {
|
||||
calls: number[];
|
||||
cost: number[];
|
||||
tokens: number[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageAlert {
|
||||
id: number;
|
||||
type: string;
|
||||
threshold_percentage: number;
|
||||
provider?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
is_sent: boolean;
|
||||
sent_at?: string;
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
billing_period: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CostProjections {
|
||||
projected_monthly_cost: number;
|
||||
cost_limit: number;
|
||||
projected_usage_percentage: number;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
total_api_calls_this_month: number;
|
||||
total_cost_this_month: number;
|
||||
usage_status: string;
|
||||
unread_alerts: number;
|
||||
}
|
||||
|
||||
export interface UsagePercentages {
|
||||
gemini_calls: number;
|
||||
openai_calls: number;
|
||||
anthropic_calls: number;
|
||||
mistral_calls: number;
|
||||
tavily_calls: number;
|
||||
serper_calls: number;
|
||||
metaphor_calls: number;
|
||||
firecrawl_calls: number;
|
||||
stability_calls: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface BillingAPIResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UsageAPIResponse extends BillingAPIResponse<UsageStats> {}
|
||||
export interface DashboardAPIResponse extends BillingAPIResponse<DashboardData> {}
|
||||
export interface AlertsAPIResponse extends BillingAPIResponse<{ alerts: UsageAlert[]; total: number; unread_count: number }> {}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// System health and monitoring types
|
||||
export interface SystemHealth {
|
||||
status: 'healthy' | 'warning' | 'critical';
|
||||
icon: string;
|
||||
recent_requests: number;
|
||||
recent_errors: number;
|
||||
error_rate: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface MonitoringAPIResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SystemHealthAPIResponse extends MonitoringAPIResponse<SystemHealth> {}
|
||||
Reference in New Issue
Block a user