Updated SEO Analysis Modal

This commit is contained in:
ajaysi
2025-09-22 21:02:32 +05:30
parent f98d49cea7
commit 12119d418b
38 changed files with 5742 additions and 2337 deletions

View File

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

View 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()
}

View File

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

View File

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

View 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

View 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

View File

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

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

View 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())

View File

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

View File

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

View File

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

View File

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

View 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;

View 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 (&lt;1%):</strong> Keyword may not be prominent enough
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Too High (&gt;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>
);
};

View 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

View 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>
);
};

View 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>
);
};

View File

@@ -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' };
},

View 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>
);
};

View 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';

View 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>
);
};

View File

@@ -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 }) => {

View File

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

View File

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

View File

@@ -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}
/>
)
};
};

View 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 };

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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