diff --git a/backend/alwrity_utils/router_manager.py b/backend/alwrity_utils/router_manager.py index 17fe3836..bea6c375 100644 --- a/backend/alwrity_utils/router_manager.py +++ b/backend/alwrity_utils/router_manager.py @@ -44,7 +44,7 @@ CORE_ROUTER_REGISTRY = [ OPTIONAL_ROUTER_REGISTRY = [ {"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}}, {"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}}, -{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}}, + {"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all", "blog_writer"}}, {"name": "wix_test", "module": "api.wix_routes", "attr": "qa_router", "features": {"all"}}, {"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}}, {"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}}, diff --git a/backend/api/blog_writer/router.py b/backend/api/blog_writer/router.py index fde63edd..daad524c 100644 --- a/backend/api/blog_writer/router.py +++ b/backend/api/blog_writer/router.py @@ -9,10 +9,12 @@ from fastapi import APIRouter, HTTPException, Depends from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field from loguru import logger +from datetime import datetime from middleware.auth_middleware import get_current_user from sqlalchemy.orm import Session from services.database import get_db as get_db_dependency from utils.text_asset_tracker import save_and_track_text_content +from models.content_asset_models import AssetType, AssetSource from models.blog_models import ( BlogResearchRequest, @@ -36,6 +38,7 @@ from models.blog_models import ( from services.blog_writer.blog_service import BlogWriterService from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier from services.llm_providers.main_text_generation import llm_text_gen +from services.content_asset_service import ContentAssetService from .task_manager import task_manager from .cache_manager import cache_manager from models.blog_models import MediumBlogGenerateRequest @@ -1260,3 +1263,233 @@ async def save_complete_blog_asset( except Exception as e: logger.error(f"Failed to save complete blog asset: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +# --------------------------------------- +# Blog Asset API (phase-by-phase saving via ContentAsset) +# --------------------------------------- + + +class BlogAssetCreateRequest(BaseModel): + research_keywords: str = Field(..., max_length=2000, description="Research keywords / topic") + topic: Optional[str] = Field(default=None, max_length=500) + word_count_target: Optional[int] = Field(default=None, ge=100, le=20000) + + +class BlogAssetUpdateRequest(BaseModel): + phase: Optional[str] = Field(default=None, pattern=r"^(research|outline|content|seo|publish)$") + topic: Optional[str] = Field(default=None, max_length=500) + selected_title: Optional[str] = Field(default=None, max_length=500) + word_count_target: Optional[int] = Field(default=None, ge=100, le=20000) + research_data: Optional[Dict[str, Any]] = None + outline_data: Optional[Dict[str, Any]] = None + content_data: Optional[Dict[str, Any]] = None + seo_data: Optional[Dict[str, Any]] = None + publish_data: Optional[Dict[str, Any]] = None + + +def _normalize_keywords(kw: str) -> str: + """Normalize keywords for duplicate comparison.""" + return " ".join(sorted(kw.lower().split())) + + +@router.post("/asset", response_model=Dict[str, Any]) +async def create_blog_asset( + request: BlogAssetCreateRequest, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Create a blog ContentAsset on research start. + Returns existing asset if duplicate keywords found (unique topics only). + """ + try: + if not current_user: + raise HTTPException(status_code=401, detail="Authentication required") + user_id = str(current_user.get("id", "")) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid user ID") + + svc = ContentAssetService(db) + normalized_kw = _normalize_keywords(request.research_keywords) + + # Duplicate check — search existing blog assets for matching keywords + existing_assets, _ = svc.get_user_assets( + user_id=user_id, + source_module=AssetSource.BLOG_WRITER, + asset_type=AssetType.TEXT, + limit=100, + ) + for asset in existing_assets: + meta = asset.asset_metadata or {} + if meta.get("normalized_keywords") == normalized_kw: + logger.info(f"Duplicate blog asset found: {asset.id}, returning existing") + return { + "success": True, + "asset": _asset_to_response(asset), + "existing": True, + } + + # Create new ContentAsset for this blog + title = request.topic or request.research_keywords[:200] + asset_metadata = { + "phase": "research", + "research_keywords": request.research_keywords, + "normalized_keywords": normalized_kw, + "word_count_target": request.word_count_target, + "topic": request.topic, + "research_data": None, + "outline_data": None, + "content_data": None, + "seo_data": None, + "publish_data": None, + } + asset = svc.create_asset( + user_id=user_id, + asset_type=AssetType.TEXT, + source_module=AssetSource.BLOG_WRITER, + filename=f"blog_{int(datetime.utcnow().timestamp())}.md", + file_url=f"/api/blog/content/pending", + title=title, + description=f"Blog: {title}", + tags=["blog", "research"], + asset_metadata=asset_metadata, + ) + logger.info(f"āœ… Created blog asset: {asset.id}") + return { + "success": True, + "asset": _asset_to_response(asset), + "existing": False, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create blog asset: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/asset/{asset_id}", response_model=Dict[str, Any]) +async def update_blog_asset( + asset_id: int, + request: BlogAssetUpdateRequest, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update a blog asset's phase, metadata, and tags.""" + try: + if not current_user: + raise HTTPException(status_code=401, detail="Authentication required") + user_id = str(current_user.get("id", "")) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid user ID") + + svc = ContentAssetService(db) + asset = svc.get_asset_by_id(asset_id, user_id) + if not asset: + raise HTTPException(status_code=404, detail="Blog asset not found") + + meta = dict(asset.asset_metadata or {}) + tags = list(asset.tags or []) + + if request.phase is not None: + meta["phase"] = request.phase + # Update tags to reflect phase + new_tags = [t for t in tags if t not in ("research", "outline", "content", "seo", "publish")] + new_tags.append(request.phase) + if "blog" not in new_tags: + new_tags.append("blog") + tags = new_tags + + if request.topic is not None: + meta["topic"] = request.topic + if request.selected_title is not None: + meta["selected_title"] = request.selected_title + if request.word_count_target is not None: + meta["word_count_target"] = request.word_count_target + + for field in ("research_data", "outline_data", "content_data", "seo_data", "publish_data"): + val = getattr(request, field, None) + if val is not None: + meta[field] = val + + if meta.get("selected_title"): + new_title = meta["selected_title"] + elif meta.get("topic"): + new_title = meta["topic"] + else: + new_title = asset.title or "Blog Post" + + updated = svc.update_asset( + asset_id=asset_id, + user_id=user_id, + title=new_title[:500], + tags=tags, + asset_metadata=meta, + ) + if not updated: + raise HTTPException(status_code=500, detail="Failed to update asset") + + logger.info(f"āœ… Updated blog asset {asset_id}: phase={meta.get('phase')}") + return {"success": True, "asset": _asset_to_response(updated)} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update blog asset {asset_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/asset/{asset_id}", response_model=Dict[str, Any]) +async def get_blog_asset( + asset_id: int, + current_user: Dict[str, Any] = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get a blog asset with all phase data.""" + try: + if not current_user: + raise HTTPException(status_code=401, detail="Authentication required") + user_id = str(current_user.get("id", "")) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid user ID") + + svc = ContentAssetService(db) + asset = svc.get_asset_by_id(asset_id, user_id) + if not asset: + raise HTTPException(status_code=404, detail="Blog asset not found") + + return {"success": True, "asset": _asset_to_response(asset, full=True)} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get blog asset {asset_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +def _asset_to_response(asset: Any, full: bool = False) -> Dict[str, Any]: + """Convert a ContentAsset to a blog asset response dict.""" + meta = asset.asset_metadata or {} + resp: Dict[str, Any] = { + "id": asset.id, + "title": asset.title, + "description": asset.description, + "tags": asset.tags or [], + "phase": meta.get("phase", "research"), + "research_keywords": meta.get("research_keywords"), + "topic": meta.get("topic"), + "selected_title": meta.get("selected_title"), + "word_count_target": meta.get("word_count_target"), + "has_research": meta.get("research_data") is not None, + "has_outline": meta.get("outline_data") is not None, + "has_content": meta.get("content_data") is not None, + "has_seo": meta.get("seo_data") is not None, + "has_publish": meta.get("publish_data") is not None, + "created_at": asset.created_at.isoformat() if asset.created_at else None, + "updated_at": asset.updated_at.isoformat() if asset.updated_at else None, + } + if full: + resp["research_data"] = meta.get("research_data") + resp["outline_data"] = meta.get("outline_data") + resp["content_data"] = meta.get("content_data") + resp["seo_data"] = meta.get("seo_data") + resp["publish_data"] = meta.get("publish_data") + return resp diff --git a/backend/api/blog_writer/task_manager.py b/backend/api/blog_writer/task_manager.py index 2f45ea2a..99b93a3c 100644 --- a/backend/api/blog_writer/task_manager.py +++ b/backend/api/blog_writer/task_manager.py @@ -256,7 +256,8 @@ class TaskManager: self.task_storage[task_id]["status"] = "running" self.task_storage[task_id]["progress_messages"] = [] - await self.update_progress(task_id, "šŸ“¦ Packaging outline and metadata...") + await self.update_progress(task_id, "šŸ“ Alwrity is preparing your blog content — this usually takes 20–40 seconds.") + await self.update_progress(task_id, "šŸ“¦ Packaging your outline sections and research data...") # Basic guard: respect global target words total_target = int(request.globalTargetWords or 1000) @@ -281,16 +282,22 @@ class TaskManager: # Check if result came from cache cache_hit = getattr(result, 'cache_hit', False) if cache_hit: - await self.update_progress(task_id, "⚔ Found cached content - loading instantly!") + await self.update_progress(task_id, "⚔ Found existing content in cache — no need to regenerate!") else: - await self.update_progress(task_id, "šŸ¤– Generated fresh content with AI...") - await self.update_progress(task_id, "✨ Post-processing and assembling sections...") + await self.update_progress(task_id, "🧠 AI is writing each section with research-backed insights and natural flow...") + await self.update_progress(task_id, "✨ Polishing content — improving structure, readability, and transitions...") # Mark completed self.task_storage[task_id]["status"] = "completed" self.task_storage[task_id]["result"] = result.dict() - await self.update_progress(task_id, f"āœ… Generated {len(result.sections)} sections successfully.") - + section_count = len(result.sections) + total_words = sum(getattr(s, 'wordCount', 0) or 0 for s in result.sections) + await self.update_progress( + task_id, + f"āœ… Content generation complete! {section_count} sections written ({total_words} words). " + "Next up: SEO Analysis to optimize your blog for search engines." + ) + # Note: Blog content tracking is handled in the status endpoint # to ensure we have proper database session and user context diff --git a/backend/api/hallucination_detector.py b/backend/api/hallucination_detector.py index ec28d9ac..ab9608bd 100644 --- a/backend/api/hallucination_detector.py +++ b/backend/api/hallucination_detector.py @@ -71,7 +71,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_ text=source.get('text', ''), published_date=source.get('publishedDate'), author=source.get('author'), - score=source.get('score', 0.5) + score=source.get('score') if source.get('score') is not None else 0.5 ) for source in claim.supporting_sources ] @@ -83,7 +83,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_ text=source.get('text', ''), published_date=source.get('publishedDate'), author=source.get('author'), - score=source.get('score', 0.5) + score=source.get('score') if source.get('score') is not None else 0.5 ) for source in claim.refuting_sources ] @@ -214,7 +214,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str text=source.get('text', ''), published_date=source.get('publishedDate'), author=source.get('author'), - score=source.get('score', 0.5) + score=source.get('score') if source.get('score') is not None else 0.5 ) for source in claim_result.supporting_sources ] @@ -226,7 +226,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str text=source.get('text', ''), published_date=source.get('publishedDate'), author=source.get('author'), - score=source.get('score', 0.5) + score=source.get('score') if source.get('score') is not None else 0.5 ) for source in claim_result.refuting_sources ] diff --git a/backend/api/writing_assistant.py b/backend/api/writing_assistant.py index 28ef468e..11cb7766 100644 --- a/backend/api/writing_assistant.py +++ b/backend/api/writing_assistant.py @@ -12,6 +12,7 @@ router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"]) class SuggestRequest(BaseModel): text: str + cursor_position: int | None = None class SourceModel(BaseModel): @@ -32,6 +33,7 @@ class SuggestionModel(BaseModel): class SuggestResponse(BaseModel): success: bool suggestions: List[SuggestionModel] + message: str = "" assistant_service = WritingAssistantService() @@ -41,9 +43,9 @@ assistant_service = WritingAssistantService() async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> SuggestResponse: try: user_id = current_user.get("id") - suggestions = await assistant_service.suggest(req.text, user_id=user_id) + suggestions = await assistant_service.suggest(req.text, user_id=user_id, cursor_position=req.cursor_position) return SuggestResponse( - success=True, + success=len(suggestions) > 0, suggestions=[ SuggestionModel( text=s.text, diff --git a/backend/app.py b/backend/app.py index 52dfcd5a..422deb30 100644 --- a/backend/app.py +++ b/backend/app.py @@ -679,9 +679,6 @@ if _is_full_mode(): if campaign_creator_router: app.include_router(campaign_creator_router) - # Include content assets router - from api.content_assets.router import router as content_assets_router - app.include_router(content_assets_router) router_group_status["platform_extensions"] = { "mounted": True, "reason": "Full mode", @@ -692,6 +689,10 @@ else: "reason": "Skipped in feature-only mode", } +# Include content assets router (always — core utility, not feature-specific) +from api.content_assets.router import router as content_assets_router +app.include_router(content_assets_router) + # Include Podcast Maker router (only when podcast feature is enabled) if _is_feature_enabled("podcast") and "all" not in get_enabled_features(): from api.podcast.router import router as podcast_router diff --git a/backend/routers/gsc_auth.py b/backend/routers/gsc_auth.py index 8c46654a..fe679433 100644 --- a/backend/routers/gsc_auth.py +++ b/backend/routers/gsc_auth.py @@ -76,12 +76,22 @@ async def handle_gsc_callback( success = gsc_service.handle_oauth_callback(code, state) + # If state verification failed, check if user is already connected + # (handles duplicate callbacks where state was consumed by a prior request) + if not success: + user_id_from_state = state.split(':')[0] if ':' in state else None + if user_id_from_state: + existing_creds = gsc_service.load_user_credentials(user_id_from_state) + if existing_creds: + logger.info(f"GSC OAuth state already consumed, but user {user_id_from_state} has valid credentials — treating as success") + success = True + if success: logger.info("GSC OAuth callback handled successfully") # Create GSC insights task immediately after successful connection try: - from services.database import SessionLocal + from services.database import get_session_for_user from services.platform_insights_monitoring_service import create_platform_insights_task # Get user_id from state (stored during OAuth flow) @@ -89,23 +99,24 @@ async def handle_gsc_callback( user_id = state.split(':')[0] if ':' in state else None if user_id: - db = SessionLocal() - try: - # Create insights task without site_url to avoid API calls - # The executor will fetch it when the task runs (weekly) - task_result = create_platform_insights_task( - user_id=user_id, - platform='gsc', - site_url=None, # Will be fetched by executor when task runs - db=db - ) - - if task_result.get('success'): - logger.info(f"Created GSC insights task for user {user_id}") - else: - logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}") - finally: - db.close() + db = get_session_for_user(user_id) + if db: + try: + task_result = create_platform_insights_task( + user_id=user_id, + platform='gsc', + site_url=None, + db=db + ) + + if task_result.get('success'): + logger.info(f"Created GSC insights task for user {user_id}") + else: + logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}") + finally: + db.close() + else: + logger.warning(f"Could not create DB session for user {user_id}") else: logger.warning(f"Could not extract user_id from state: {state}") except Exception as e: @@ -125,7 +136,10 @@ async def handle_gsc_callback( """ - return HTMLResponse(content=html) + return HTMLResponse( + content=html, + headers={"Cross-Origin-Opener-Policy": "unsafe-none"}, + ) else: logger.error("Failed to handle GSC OAuth callback") html = """ @@ -140,7 +154,11 @@ async def handle_gsc_callback( """ - return HTMLResponse(status_code=400, content=html) + return HTMLResponse( + status_code=400, + content=html, + headers={"Cross-Origin-Opener-Policy": "unsafe-none"}, + ) except Exception as e: logger.error(f"Error handling GSC OAuth callback: {e}") @@ -157,7 +175,11 @@ async def handle_gsc_callback( """ - return HTMLResponse(status_code=500, content=html) + return HTMLResponse( + status_code=500, + content=html, + headers={"Cross-Origin-Opener-Policy": "unsafe-none"}, + ) @router.get("/sites") async def get_gsc_sites(user: dict = Depends(get_current_user)): diff --git a/backend/services/blog_writer/content/medium_blog_generator.py b/backend/services/blog_writer/content/medium_blog_generator.py index 6b33eddc..3c3f0e68 100644 --- a/backend/services/blog_writer/content/medium_blog_generator.py +++ b/backend/services/blog_writer/content/medium_blog_generator.py @@ -122,9 +122,6 @@ class MediumBlogGenerator: 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], } @@ -136,7 +133,6 @@ class MediumBlogGenerator: - 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. @@ -154,40 +150,19 @@ class MediumBlogGenerator: "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"Write blog content for the following sections. Total target: {req.globalTargetWords or 1000} words, 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" + "- Uses double line breaks (\\n\\n) between paragraphs\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)}" + "- Ends with a strong concluding paragraph\n\n" + "Return a JSON object with 'title' and 'sections' array. Each section must have 'id', 'heading', 'content', 'wordCount', and 'sources'.\n\n" + f"Sections:\n{json.dumps(payload, ensure_ascii=False, indent=2)}" ) try: @@ -195,7 +170,9 @@ class MediumBlogGenerator: prompt=prompt, json_struct=schema, system_prompt=system, - user_id=user_id + user_id=user_id, + max_tokens=None, + temperature=0.3, ) except HTTPException: # Re-raise HTTPExceptions (e.g., 429 subscription limit) to preserve error details diff --git a/backend/services/blog_writer/research/exa_provider.py b/backend/services/blog_writer/research/exa_provider.py index 951123fa..943fd6cd 100644 --- a/backend/services/blog_writer/research/exa_provider.py +++ b/backend/services/blog_writer/research/exa_provider.py @@ -322,7 +322,7 @@ class ExaResearchProvider(BaseProvider): 'text': getattr(result, 'text', ''), 'publishedDate': getattr(result, 'publishedDate', ''), 'author': getattr(result, 'author', ''), - 'score': getattr(result, 'score', 0.5), + 'score': (lambda v: v if v is not None else 0.5)(getattr(result, 'score', 0.5)), }) # Track usage diff --git a/backend/services/database.py b/backend/services/database.py index da655b74..13c21295 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -31,6 +31,7 @@ from models.product_marketing_models import Campaign, CampaignProposal, Campaign from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport # Podcast Maker models use SubscriptionBase, but import to ensure models are registered from models.podcast_models import PodcastProject + # Research models use SubscriptionBase from models.research_models import ResearchProject # Video Studio models diff --git a/backend/services/gsc_brainstorm_service.py b/backend/services/gsc_brainstorm_service.py index bf11b096..23a02fcb 100644 --- a/backend/services/gsc_brainstorm_service.py +++ b/backend/services/gsc_brainstorm_service.py @@ -2,8 +2,9 @@ GSC Brainstorm Service for ALwrity. Analyzes Google Search Console data to suggest blog topics the user should write about. -Combines rule-based heuristics (high-impression/low-CTR keywords, near-page-1 positions) -with LLM-powered strategic recommendations tailored to the user's topic intent. +Combines rule-based heuristics with LLM-powered strategic recommendations tailored to +the user's topic intent. Designed for non-SEO-experts: every insight includes plain-English +explanations of WHY it matters and WHAT to do about it. """ import json @@ -21,9 +22,10 @@ class GSCBrainstormService: Flow: 1. Fetch real GSC search analytics (query + page data, 30 days) - 2. Apply rule-based filters (Content Optimization, Content Enhancement, Keyword Gap) - 3. Generate LLM-powered strategic recommendations contextualised to the user's keywords - 4. Return structured results + 2. Compute derived metrics (CTR benchmarks, estimated traffic uplift, content formats) + 3. Apply rule-based filters (Quick Wins, Optimization, Enhancement, Rising Stars, Page Issues) + 4. Generate LLM-powered strategic recommendations contextualised to the user's keywords + 5. Return structured results with all data exposed for rich frontend display """ def __init__(self, gsc_service: GSCService = None): @@ -39,18 +41,8 @@ class GSCBrainstormService: keywords: str, site_url: Optional[str] = None, ) -> Dict[str, Any]: - """ - Generate blog topic suggestions from the user's GSC data. - - Args: - user_id: Clerk user ID (must have GSC connected). - keywords: User's 3+ word topic intent (e.g. "content marketing strategy"). - site_url: Optional site URL; auto-selected from user's first GSC site if omitted. - - Returns: - Dict with content_opportunities, keyword_gaps, ai_recommendations, summary. - """ self._user_id = user_id + # 1. Resolve site_url if not site_url: sites = self.gsc_service.get_site_list(user_id) @@ -59,6 +51,8 @@ class GSCBrainstormService: "error": "No GSC sites found. Make sure your site is verified in Google Search Console.", "content_opportunities": [], "keyword_gaps": [], + "quick_wins": [], + "page_opportunities": [], "ai_recommendations": {}, "summary": {}, } @@ -80,6 +74,8 @@ class GSCBrainstormService: "error": analytics.get("error", "Failed to fetch GSC data"), "content_opportunities": [], "keyword_gaps": [], + "quick_wins": [], + "page_opportunities": [], "ai_recommendations": {}, "summary": {}, } @@ -93,9 +89,11 @@ class GSCBrainstormService: if not keywords_data: return { - "error": "No keyword data available for the selected period.", + "error": "No keyword data available for the selected period. This usually means your site is new to GSC or hasn't received search traffic yet.", "content_opportunities": [], "keyword_gaps": [], + "quick_wins": [], + "page_opportunities": [], "ai_recommendations": {}, "summary": { "site_url": site_url, @@ -107,18 +105,23 @@ class GSCBrainstormService: # 4. Rule-based analysis content_opportunities = self._identify_content_opportunities(keywords_data) keyword_gaps = self._identify_keyword_gaps(keywords_data) + quick_wins = self._identify_quick_wins(keywords_data) + page_opportunities = self._identify_page_opportunities(pages_data) # 5. Summary metrics summary = self._compute_summary(keywords_data, pages_data, site_url, start_date, end_date) - # 6. AI recommendations (best-effort; don't fail the whole request on LLM error) + # 6. AI recommendations ai_recommendations = self._generate_ai_recommendations( - keywords_data, pages_data, summary, keywords + keywords_data, pages_data, summary, keywords, + content_opportunities, quick_wins, keyword_gaps, ) return { "content_opportunities": content_opportunities, "keyword_gaps": keyword_gaps, + "quick_wins": quick_wins, + "page_opportunities": page_opportunities, "ai_recommendations": ai_recommendations, "summary": summary, } @@ -168,39 +171,53 @@ class GSCBrainstormService: opportunities: List[Dict[str, Any]] = [] # Rule 1: Content Optimization — high impressions, low CTR + # Meaning: Google is SHOWING your page for this query but people aren't clicking. + # The content probably ranks but title/meta/snippet isn't compelling enough. for kw in keywords_data: if kw["impressions"] > 500 and kw["ctr"] < 3: + estimated_gain = int(kw["impressions"] * 0.05) - kw["clicks"] opportunities.append({ "type": "Content Optimization", "keyword": kw["keyword"], "opportunity": ( - f"Optimize existing content for '{kw['keyword']}' " - f"to improve CTR from {kw['ctr']:.1f}% " - f"(position {kw['position']:.1f})" + f"Your site appears for '{kw['keyword']}' ({kw['impressions']:,} times/month) " + f"but only {kw['ctr']:.1f}% click. Improving your title and meta description " + f"could bring ~{max(estimated_gain, 5)} more clicks/month." ), - "potential_impact": "High", + "potential_impact": "High" if kw["impressions"] > 1000 else "Medium", "current_position": kw["position"], + "current_ctr": kw["ctr"], "impressions": kw["impressions"], + "clicks": kw["clicks"], + "estimated_traffic_gain": max(estimated_gain, 5), "priority": "High" if kw["impressions"] > 1000 else "Medium", + "suggested_format": GSCBrainstormService._suggest_format(kw["keyword"]), }) # Rule 2: Content Enhancement — positions 11-20 with decent impressions + # Meaning: You're on page 2 of Google. A small content boost could push you to page 1, + # where CTR increases dramatically (page 1 gets ~95% of all clicks). for kw in keywords_data: if 10 < kw["position"] <= 20 and kw["impressions"] > 100: + estimated_gain = int(kw["impressions"] * 0.08) opportunities.append({ "type": "Content Enhancement", "keyword": kw["keyword"], "opportunity": ( - f"Enhance content for '{kw['keyword']}' to move from " - f"position {kw['position']:.1f} to the first page" + f"'{kw['keyword']}' ranks #{kw['position']:.0f} (page 2). " + f"Moving to page 1 could capture ~{estimated_gain} more clicks/month " + f"from {kw['impressions']:,} impressions." ), - "potential_impact": "Medium", + "potential_impact": "High" if kw["impressions"] > 500 else "Medium", "current_position": kw["position"], + "current_ctr": kw["ctr"], "impressions": kw["impressions"], - "priority": "Medium", + "clicks": kw["clicks"], + "estimated_traffic_gain": estimated_gain, + "priority": "High" if kw["impressions"] > 500 else "Medium", + "suggested_format": GSCBrainstormService._suggest_format(kw["keyword"]), }) - # Sort by impressions descending, keep top 10 opportunities.sort(key=lambda x: x["impressions"], reverse=True) return opportunities[:10] @@ -212,15 +229,111 @@ class GSCBrainstormService: for kw in keywords_data: if 4 <= kw["position"] <= 20 and kw["impressions"] >= 50: + # Estimate traffic gain if this keyword moved to position 1-3 + # Position 1 avg CTR ~31%, position 3 ~11%, current position CTR estimate + position_1_ctr = 31.0 + current_ctr = kw["ctr"] + estimated_gain = max(int(kw["impressions"] * (position_1_ctr - current_ctr) / 100), 1) + gaps.append({ "keyword": kw["keyword"], "position": kw["position"], "impressions": kw["impressions"], + "current_ctr": kw["ctr"], + "clicks": kw["clicks"], + "estimated_traffic_if_page1": estimated_gain, + "gap_from_page1": round(kw["position"] - 3, 1), }) gaps.sort(key=lambda x: x["impressions"], reverse=True) return gaps[:10] + @staticmethod + def _identify_quick_wins( + keywords_data: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Keywords already on page 1 (positions 4-10) that could reach top 3 + with minor improvements — the highest-ROI opportunities.""" + quick_wins: List[Dict[str, Any]] = [] + + for kw in keywords_data: + if 4 <= kw["position"] <= 10 and kw["impressions"] >= 100: + # Position 3 CTR ā‰ˆ 11%, position 5 CTR ā‰ˆ 6% + # Small improvements can yield big traffic gains + target_ctr = 11.0 # approximate CTR for position 3 + estimated_gain = max(int(kw["impressions"] * (target_ctr - kw["ctr"]) / 100), 1) + + quick_wins.append({ + "keyword": kw["keyword"], + "position": kw["position"], + "impressions": kw["impressions"], + "current_ctr": kw["ctr"], + "clicks": kw["clicks"], + "estimated_traffic_gain": estimated_gain, + "reason": ( + f"Already on page 1 at position #{kw['position']:.0f}. " + f"Optimizing this page could increase CTR from {kw['ctr']:.1f}% " + f"to ~{target_ctr:.0f}%, gaining ~{estimated_gain} clicks/month." + ), + }) + + quick_wins.sort(key=lambda x: x["estimated_traffic_gain"], reverse=True) + return quick_wins[:5] + + @staticmethod + def _identify_page_opportunities( + pages_data: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Pages with high impressions but low CTR — the content or meta needs work.""" + opportunities: List[Dict[str, Any]] = [] + + for pg in pages_data: + if pg["impressions"] > 300 and pg["ctr"] < 2.0: + short_page = pg["page"].rstrip("/").rsplit("/", 1)[-1].replace("-", " ").title() + if len(short_page) > 60: + short_page = short_page[:57] + "..." + opportunities.append({ + "page": pg["page"], + "page_title": short_page, + "impressions": pg["impressions"], + "clicks": pg["clicks"], + "current_ctr": pg["ctr"], + "current_position": pg["position"], + "reason": ( + f"This page gets {pg['impressions']:,} impressions but only {pg['ctr']:.1f}% CTR. " + f"Reviewing the title and meta description could significantly boost clicks." + ), + }) + + opportunities.sort(key=lambda x: x["impressions"], reverse=True) + return opportunities[:5] + + # ------------------------------------------------------------------ # + # Content format suggestion + # ------------------------------------------------------------------ # + + @staticmethod + def _suggest_format(keyword: str) -> str: + """Suggest a content format based on keyword patterns.""" + kw = keyword.lower() + if any(w in kw for w in ["how to", "how do", "guide", "tutorial", "steps"]): + return "How-To Guide" + if any(w in kw for w in ["vs", "versus", "compare", "comparison", "difference"]): + return "Comparison" + if any(w in kw for w in ["best", "top", "recommended", "review", "reviews"]): + return "Top Picks / Review" + if any(w in kw for w in ["what is", "definition", "meaning", "explained"]): + return "Explainer" + if any(w in kw for w in ["list", "examples", "ideas", "tips", "ways"]): + return "Listicle" + if any(w in kw for w in ["free", "cheap", "alternative", "budget"]): + return "Budget / Alternative" + if any(w in kw for w in ["template", "calculator", "tool", "checker"]): + return "Tool / Template" + if any(w in kw for w in ["2024", "2025", "2026", "trends", "prediction", "future"]): + return "Trend Report" + return "In-Depth Article" + # ------------------------------------------------------------------ # # Summary metrics # ------------------------------------------------------------------ # @@ -248,6 +361,16 @@ class GSCBrainstormService: top_keywords = sorted(keywords_data, key=lambda x: x["impressions"], reverse=True)[:5] top_pages = sorted(pages_data, key=lambda x: x["clicks"], reverse=True)[:3] + # Health score: 0-100 based on how many keywords are on page 1 + total_kw = len(keywords_data) or 1 + page1_pct = (pos_1_3 + pos_4_10) / total_kw * 100 + top3_pct = pos_1_3 / total_kw * 100 + health_score = round(min(top3_pct * 3 + page1_pct * 0.7, 100), 0) + + # CTR benchmark: industry average is ~3.1% for position 1-10 + ctr_benchmark = 3.1 + ctr_vs_benchmark = round(avg_ctr - ctr_benchmark, 2) + return { "site_url": site_url, "date_range": {"start": start_date, "end": end_date}, @@ -256,6 +379,8 @@ class GSCBrainstormService: "total_clicks": total_clicks, "avg_ctr": avg_ctr, "avg_position": avg_position, + "ctr_vs_benchmark": ctr_vs_benchmark, + "health_score": health_score, "keyword_distribution": { "positions_1_3": pos_1_3, "positions_4_10": pos_4_10, @@ -263,11 +388,22 @@ class GSCBrainstormService: "positions_21_plus": pos_21_plus, }, "top_keywords": [ - {"keyword": kw["keyword"], "impressions": kw["impressions"], "position": kw["position"]} + { + "keyword": kw["keyword"], + "impressions": kw["impressions"], + "clicks": kw["clicks"], + "position": kw["position"], + "ctr": kw["ctr"], + } for kw in top_keywords ], "top_pages": [ - {"page": pg["page"], "clicks": pg["clicks"], "impressions": pg["impressions"]} + { + "page": pg["page"], + "clicks": pg["clicks"], + "impressions": pg["impressions"], + "ctr": pg["ctr"], + } for pg in top_pages ], } @@ -282,60 +418,110 @@ class GSCBrainstormService: pages_data: List[Dict], summary: Dict, user_keywords: str, + content_opportunities: List[Dict], + quick_wins: List[Dict], + keyword_gaps: List[Dict], ) -> Dict[str, Any]: try: - top_kw = ", ".join(kw["keyword"] for kw in summary.get("top_keywords", [])) + top_kw_list = summary.get("top_keywords", []) + top_kw_str = "\n".join( + f" • {kw['keyword']}: {kw['impressions']:,} impressions, position {kw['position']}, {kw['ctr']:.1f}% CTR" + for kw in top_kw_list[:10] + ) dist = summary.get("keyword_distribution", {}) - prompt = f"""Analyze this Google Search Console data and suggest blog topics the user should write about. + opp_str = "" + if content_opportunities: + opp_str = "\nCONTENT OPPORTUNITIES (rule-based findings):\n" + "\n".join( + f" • {o['keyword']}: {o['opportunity']}" + for o in content_opportunities[:5] + ) + else: + opp_str = "\nNo major content opportunities detected from rule-based analysis." -USER'S TOPIC INTENT: "{user_keywords}" + qw_str = "" + if quick_wins: + qw_str = "\nQUICK WINS (already on page 1, easy to optimize):\n" + "\n".join( + f" • {q['keyword']}: position #{q['position']:.0f}, {q['current_ctr']:.1f}% CTR, est. +{q['estimated_traffic_gain']} clicks/month" + for q in quick_wins[:3] + ) -SEARCH PERFORMANCE SUMMARY: -- Total Keywords Tracked: {summary.get('total_keywords_analyzed', 0)} + prompt = f"""You are an expert SEO content strategist analyzing real Google Search Console data for a blog writer. + +The user wants to write about: "{user_keywords}" + +Here is their GSC data for the last 30 days: + +PERFORMANCE OVERVIEW: +- Total Keywords: {summary.get('total_keywords_analyzed', 0)} - Total Impressions: {summary.get('total_impressions', 0):,} - Total Clicks: {summary.get('total_clicks', 0):,} -- Average CTR: {summary.get('avg_ctr', 0):.2f}% +- Average CTR: {summary.get('avg_ctr', 0):.2f}% (industry avg for positions 1-10 is ~3.1%) - Average Position: {summary.get('avg_position', 0):.1f} +- SEO Health Score: {summary.get('health_score', 0)}/100 -TOP PERFORMING KEYWORDS: -{top_kw} +TOP KEYWORDS BY IMPRESSIONS: +{top_kw_str} KEYWORD POSITION DISTRIBUTION: -- Positions 1-3: {dist.get('positions_1_3', 0)} -- Positions 4-10: {dist.get('positions_4_10', 0)} -- Positions 11-20: {dist.get('positions_11_20', 0)} -- Positions 21+: {dist.get('positions_21_plus', 0)} +- Position 1-3 (top results): {dist.get('positions_1_3', 0)} keywords +- Position 4-10 (page 1): {dist.get('positions_4_10', 0)} keywords +- Position 11-20 (page 2): {dist.get('positions_11_20', 0)} keywords +- Position 21+ (page 3+): {dist.get('positions_21_plus', 0)} keywords +{opp_str} +{qw_str} -Based on this data, provide: +Based on this data, provide EXACT blog post suggestions the user should write. -1. IMMEDIATE TOPIC OPPORTUNITIES (0-30 days): - - Specific blog post titles the user should write - - Each tied to a keyword opportunity from the data - - 3-5 suggestions +For each suggestion include: +1. A specific, compelling blog post TITLE (not vague topic) +2. The keyword it targets and why (based on the data above) +3. The recommended content format (how-to, listicle, comparison, etc.) +4. Estimated impact (how many more clicks/month they could gain) -2. CONTENT STRATEGY TOPICS (1-3 months): - - New topic clusters to build authority - - Content pillar ideas - - 3-5 suggestions - -3. LONG-TERM CONTENT VISION (3-12 months): - - Market expansion topics - - Authority-building content ideas - - 3-5 suggestions - -IMPORTANT: Relate every topic suggestion to the user's interest in "{user_keywords}". -Return your response in this exact JSON format: +Return your response in this EXACT JSON format (no markdown, no code fences): {{ - "immediate_opportunities": ["topic 1", "topic 2", "topic 3"], - "content_strategy": ["strategy 1", "strategy 2", "strategy 3"], - "long_term_strategy": ["vision 1", "vision 2", "vision 3"] -}}""" + "immediate_opportunities": [ + {{ + "title": "Specific Blog Post Title Here", + "keyword": "target keyword", + "reason": "Why this will work based on the data", + "format": "How-To Guide | Listicle | Comparison | Explainer | etc.", + "estimated_impact": "Estimated X more clicks/month" + }} + ], + "content_strategy": [ + {{ + "title": "Pillar Content Title", + "keyword": "target keyword", + "reason": "Strategic reasoning", + "format": "Content format", + "estimated_impact": "Expected impact" + }} + ], + "long_term_strategy": [ + {{ + "title": "Authority Building Title", + "keyword": "target keyword", + "reason": "Long-term reasoning", + "format": "Content format", + "estimated_impact": "Expected long-term impact" + }} + ] +}} + +IMPORTANT: +- Provide 3-5 items in each category +- Every suggestion MUST relate to the user's interest in "{user_keywords}" +- Titles should be specific and compelling, like real blog post headlines +- Use the data above to justify each recommendation +- Prioritize keywords with high impressions but low CTR or low position""" system_prompt = ( - "You are an enterprise SEO content strategist. Provide specific, data-driven " - "blog topic suggestions that will improve the user's search performance. " - "Always respond with valid JSON matching the requested format." + "You are an expert SEO content strategist. You analyze Google Search Console data " + "and provide specific, actionable blog post recommendations that will drive real traffic. " + "You always respond with valid JSON matching the requested format. " + "Every recommendation must be backed by the data provided." ) result = llm_text_gen( @@ -350,27 +536,58 @@ Return your response in this exact JSON format: if parsed: return parsed - return self._fallback_ai_recommendations(keywords_data) + return self._fallback_ai_recommendations(keywords_data, content_opportunities, quick_wins) except Exception as e: logger.warning(f"GSC brainstorm AI recommendations failed: {e}") - return self._fallback_ai_recommendations(keywords_data) + return self._fallback_ai_recommendations(keywords_data, content_opportunities, quick_wins) - @staticmethod - def _parse_ai_response(raw: str) -> Optional[Dict[str, List[str]]]: + def _parse_ai_response(self, raw: str) -> Optional[Dict[str, Any]]: try: - json_start = raw.find("{") - json_end = raw.rfind("}") + 1 + # Strip markdown code fences if present + cleaned = raw.strip() + if cleaned.startswith("```"): + first_newline = cleaned.find("\n") + if first_newline != -1: + cleaned = cleaned[first_newline + 1:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3].strip() + + json_start = cleaned.find("{") + json_end = cleaned.rfind("}") + 1 if json_start == -1 or json_end == 0: return None - chunk = raw[json_start:json_end] + chunk = cleaned[json_start:json_end] parsed = json.loads(chunk) + def normalize_section(section: Any) -> List[Dict[str, str]]: + if not isinstance(section, list): + return [] + result = [] + for item in section: + if isinstance(item, str): + result.append({ + "title": item.split(":")[0].strip() if ":" in item else item[:60], + "keyword": "", + "reason": item, + "format": "", + "estimated_impact": "", + }) + elif isinstance(item, dict): + result.append({ + "title": str(item.get("title", "")), + "keyword": str(item.get("keyword", "")), + "reason": str(item.get("reason", "")), + "format": str(item.get("format", "")), + "estimated_impact": str(item.get("estimated_impact", "")), + }) + return result + return { - "immediate_opportunities": parsed.get("immediate_opportunities", [])[:5], - "content_strategy": parsed.get("content_strategy", [])[:5], - "long_term_strategy": parsed.get("long_term_strategy", [])[:5], + "immediate_opportunities": normalize_section(parsed.get("immediate_opportunities", []))[:5], + "content_strategy": normalize_section(parsed.get("content_strategy", []))[:5], + "long_term_strategy": normalize_section(parsed.get("long_term_strategy", []))[:5], } except (json.JSONDecodeError, ValueError) as e: logger.warning(f"Failed to parse AI brainstorm response as JSON: {e}") @@ -379,26 +596,53 @@ Return your response in this exact JSON format: @staticmethod def _fallback_ai_recommendations( keywords_data: List[Dict], + content_opportunities: List[Dict], + quick_wins: List[Dict], ) -> Dict[str, Any]: top_kw = keywords_data[:3] if keywords_data else [] immediate = [] - for kw in top_kw: - immediate.append( - f"Write a comprehensive guide on '{kw['keyword']}' " - f"(currently at position {kw['position']:.1f} with " - f"{kw['impressions']} impressions)" - ) + + # Build from quick wins first (highest ROI) + for qw in quick_wins[:2]: + immediate.append({ + "title": f"How to Rank #{int(qw['position'])} for '{qw['keyword']}' — Optimization Guide", + "keyword": qw["keyword"], + "reason": qw.get("reason", f"Already on page 1 at position {qw['position']:.0f}"), + "format": "How-To Guide", + "estimated_impact": f"+{qw.get('estimated_traffic_gain', 10)} clicks/month", + }) + + # Then from content opportunities + for opp in content_opportunities[:2]: + immediate.append({ + "title": f"Complete Guide to {opp['keyword'].title()}", + "keyword": opp["keyword"], + "reason": opp.get("opportunity", f"{opp['impressions']:,} impressions with room to improve"), + "format": opp.get("suggested_format", "In-Depth Article"), + "estimated_impact": f"+{opp.get('estimated_traffic_gain', 10)} clicks/month", + }) + + # Fill remaining with top keywords + remaining = 5 - len(immediate) + for kw in top_kw[:remaining]: + immediate.append({ + "title": f"The Ultimate Guide to {kw['keyword'].title()}", + "keyword": kw["keyword"], + "reason": f"Top keyword with {kw['impressions']:,} impressions (position {kw['position']:.1f})", + "format": "In-Depth Article", + "estimated_impact": f"+{max(int(kw['impressions'] * 0.03), 5)} clicks/month", + }) return { - "immediate_opportunities": immediate or ["No keyword data available for recommendations"], + "immediate_opportunities": immediate or [{"title": "No keyword data available", "keyword": "", "reason": "Connect GSC to get personalized suggestions", "format": "", "estimated_impact": ""}], "content_strategy": [ - "Develop topic clusters around your top-performing keywords", - "Create comparison and vs-style content for competitive terms", - "Build FAQ sections targeting question-based queries", + {"title": "Topic Cluster: Build Authority Around Your Core Topics", "keyword": "", "reason": "Clustered content ranks higher and captures more long-tail queries", "format": "Pillar Page + Spokes", "estimated_impact": "+50-200 clicks/month over 3 months"}, + {"title": "Comparison Guide: Your Product vs. Alternatives", "keyword": "", "reason": "Comparison content captures high-intent searchers ready to decide", "format": "Comparison", "estimated_impact": "+20-80 clicks/month"}, + {"title": "FAQ: Answer What Your Audience Is Asking", "keyword": "", "reason": "FAQs capture featured snippets and voice search queries", "format": "FAQ / Listicle", "estimated_impact": "+30-100 clicks/month"}, ], "long_term_strategy": [ - "Build domain authority through pillar content", - "Expand into adjacent topic areas", - "Develop thought leadership content series", + {"title": "Pillar Content: The Definitive Resource in Your Niche", "keyword": "", "reason": "Comprehensive guides become authoritative references that attract backlinks", "format": "Long-Form Guide", "estimated_impact": "+100-500 clicks/month over 6-12 months"}, + {"title": "Trend Report: What's Next in Your Industry", "keyword": "", "reason": "Forward-looking content captures emerging search demand early", "format": "Trend Report", "estimated_impact": "+50-200 clicks/month"}, + {"title": "Thought Leadership: Expert Roundup and Insights", "keyword": "", "reason": "Expert content builds E-E-A-T signals that improve overall domain authority", "format": "Expert Roundup", "estimated_impact": "+30-100 clicks/month per piece"}, ], } \ No newline at end of file diff --git a/backend/services/gsc_service.py b/backend/services/gsc_service.py index e6ea9a4a..9c8a67d4 100644 --- a/backend/services/gsc_service.py +++ b/backend/services/gsc_service.py @@ -250,10 +250,10 @@ class GSCService: flow = Flow.from_client_config( self.client_config, scopes=self.scopes, - redirect_uri=redirect_uri + redirect_uri=redirect_uri, + autogenerate_code_verifier=False, ) - - # Use a custom state that includes user_id for routing the callback to the correct DB + random_state = secrets.token_urlsafe(32) state = f"{user_id}:{random_state}" @@ -300,7 +300,7 @@ class GSCService: logger.error(f"User database not found for user {user_id}") return False - # Verify state in user's DB + # Verify state in user's DB (but don't delete yet — delete after successful token exchange) with sqlite3.connect(db_path) as conn: cursor = conn.cursor() cursor.execute('SELECT user_id FROM gsc_oauth_states WHERE state = ?', (state,)) @@ -309,10 +309,6 @@ class GSCService: if not result: logger.error(f"Invalid or expired GSC OAuth state for user {user_id}") return False - - # Clean up state - cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,)) - conn.commit() # Exchange code for credentials if not self.client_config: @@ -322,12 +318,22 @@ class GSCService: flow = Flow.from_client_config( self.client_config, scopes=self.scopes, - redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback') + redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback'), + autogenerate_code_verifier=False, ) flow.fetch_token(code=authorization_code) credentials = flow.credentials + # State consumed successfully — clean up + try: + with sqlite3.connect(db_path) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,)) + conn.commit() + except Exception as cleanup_err: + logger.warning(f"Failed to clean up OAuth state: {cleanup_err}") + # Save credentials return self.save_user_credentials(user_id, credentials) diff --git a/backend/services/hallucination_detector.py b/backend/services/hallucination_detector.py index 196f6147..fcb41190 100644 --- a/backend/services/hallucination_detector.py +++ b/backend/services/hallucination_detector.py @@ -343,18 +343,28 @@ class HallucinationDetector: logger.error(f"Error in batch evidence search: {str(e)}") return [] + def _map_source_refs_from_reasoning(self, reasoning: str, sources: List[Dict[str, Any]]) -> List[int]: + """Parse 'Source N' references from reasoning text and return 0-based indices.""" + import re + indices = set() + for match in re.finditer(r'Source\s+(\d+)', reasoning): + ref = int(match.group(1)) + if 1 <= ref <= len(sources): + indices.add(ref - 1) # convert 1-based → 0-based + return sorted(indices) + async def _assess_claims_batch(self, claims: List[str], sources: List[Dict[str, Any]], user_id: str = None) -> List[Claim]: """Assess multiple claims against sources in one LLM call.""" try: claims_to_assess = claims[:3] combined_sources = "\n\n".join([ - f"Source {i+1}: {src.get('url','')}\nText: {src.get('text','')[:1000]}" + f"Source [{i}]: {src.get('url','')}\nText: {src.get('text','')[:1000]}" for i, src in enumerate(sources) ]) claims_text = "\n".join([ - f"Claim {i+1}: {claim}" + f"Claim {i}: {claim}" for i, claim in enumerate(claims_to_assess) ]) @@ -367,12 +377,14 @@ class HallucinationDetector: ' "claim_index": 0,\n' ' "assessment": "supported" or "refuted" or "insufficient_information",\n' ' "confidence": number between 0.0 and 1.0,\n' - ' "supporting_sources": [array of source indices that support the claim],\n' - ' "refuting_sources": [array of source indices that refute the claim],\n' + ' "supporting_sources": [array of 0-based source indices, e.g. [0, 2] for Source [0] and Source [2]],\n' + ' "refuting_sources": [array of 0-based source indices, e.g. [1] for Source [1]],\n' ' "reasoning": "brief explanation of your assessment"\n' ' }\n' ' ]\n' "}\n\n" + "IMPORTANT: Source indices are 0-based. Source [0] is the first source, Source [1] is the second, etc.\n" + "For every 'supported' or 'refuted' claim you MUST include the relevant source indices.\n\n" f"Claims to verify:\n{claims_text}\n\n" f"Sources:\n{combined_sources}\n\n" "Return only the JSON object:" @@ -407,6 +419,15 @@ class HallucinationDetector: if isinstance(idx, int) and 0 <= idx < len(sources): refuting_sources.append(sources[idx]) + # Fallback: parse "Source N" from reasoning text when LLM omits indices + if not supporting_sources and not refuting_sources and sources and assessment.get('reasoning'): + ref_indices = self._map_source_refs_from_reasoning(assessment.get('reasoning', ''), sources) + if ref_indices: + if assessment.get('assessment') == 'supported': + supporting_sources = [sources[i] for i in ref_indices] + elif assessment.get('assessment') == 'refuted': + refuting_sources = [sources[i] for i in ref_indices] + verified_claims.append(Claim( text=claim, confidence=float(assessment.get('confidence', 0.5)), @@ -464,7 +485,7 @@ class HallucinationDetector: """Assess whether sources support or refute the claim using LLM.""" try: combined_sources = "\n\n".join([ - f"Source {i+1}: {src.get('url','')}\nText: {src.get('text','')[:2000]}" + f"Source [{i}]: {src.get('url','')}\nText: {src.get('text','')[:2000]}" for i, src in enumerate(sources) ]) @@ -474,10 +495,12 @@ class HallucinationDetector: "{\n" ' "assessment": "supported" or "refuted" or "insufficient_information",\n' ' "confidence": number between 0.0 and 1.0,\n' - ' "supporting_sources": [array of source indices that support the claim],\n' - ' "refuting_sources": [array of source indices that refute the claim],\n' + ' "supporting_sources": [array of 0-based source indices, e.g. [0, 2] for Source [0] and Source [2]],\n' + ' "refuting_sources": [array of 0-based source indices, e.g. [1] for Source [1]],\n' ' "reasoning": "brief explanation of your assessment"\n' "}\n\n" + "IMPORTANT: Source indices are 0-based. Source [0] is the first source, Source [1] is the second, etc.\n" + "For 'supported' or 'refuted' you MUST include the relevant source indices.\n\n" f"Claim to verify: {claim}\n\n" f"Sources:\n{combined_sources}\n\n" "Return only the JSON object:" @@ -508,6 +531,15 @@ class HallucinationDetector: if isinstance(idx, int) and 0 <= idx < len(sources): refuting_sources.append(sources[idx]) + # Fallback: parse "Source N" from reasoning text when LLM omits indices + if not supporting_sources and not refuting_sources and sources and result.get('reasoning'): + ref_indices = self._map_source_refs_from_reasoning(result.get('reasoning', ''), sources) + if ref_indices: + if result.get('assessment') == 'supported': + supporting_sources = [sources[i] for i in ref_indices] + elif result.get('assessment') == 'refuted': + refuting_sources = [sources[i] for i in ref_indices] + # Validate assessment value valid_assessments = ['supported', 'refuted', 'insufficient_information'] if result['assessment'] not in valid_assessments: diff --git a/backend/services/llm_providers/main_text_generation.py b/backend/services/llm_providers/main_text_generation.py index 77f30a86..015f2182 100644 --- a/backend/services/llm_providers/main_text_generation.py +++ b/backend/services/llm_providers/main_text_generation.py @@ -46,6 +46,7 @@ def llm_text_gen( preferred_provider: Optional[str] = None, flow_type: Optional[str] = None, max_tokens: Optional[int] = None, + temperature: Optional[float] = None, ) -> str: """ Generate text using Language Model (LLM) based on the provided prompt. @@ -58,6 +59,8 @@ def llm_text_gen( preferred_hf_models (list, optional): Preferred HuggingFace models. preferred_provider (str, optional): Preferred provider (google, huggingface). flow_type (str, optional): Flow type for logging (e.g., 'sif_agent', 'premium_tool'). + max_tokens (int, optional): Max tokens for response. If None, provider default is used. + temperature (float, optional): Temperature for generation (0.0-1.0). If None, defaults to 0.7. Returns: str: Generated text based on the prompt. @@ -75,9 +78,8 @@ def llm_text_gen( # Set default values for LLM parameters gpt_provider = "google" # Default to Google Gemini model = "gemini-2.0-flash-001" - temperature = 0.7 - if max_tokens is None: - max_tokens = 4000 + if temperature is None: + temperature = 0.7 top_p = 0.9 n = 1 fp = 16 diff --git a/backend/services/writing_assistant.py b/backend/services/writing_assistant.py index a486a213..8a438afa 100644 --- a/backend/services/writing_assistant.py +++ b/backend/services/writing_assistant.py @@ -1,6 +1,7 @@ import os +import re import asyncio -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from dataclasses import dataclass from loguru import logger import random @@ -17,42 +18,33 @@ class WritingSuggestion: class WritingAssistantService: """ - Minimal writing assistant that combines Exa search with Gemini continuation. - - Exa provides relevant sources with content snippets - - Gemini generates a short, cited continuation based on current text and sources + Writing assistant that combines Exa search with LLM continuation. + - Searches relevant sources using the content near the cursor position + - Generates a short continuation grounded in sources + - Confidence derived from source availability and quality """ def __init__(self) -> None: - # COST CONTROL: Daily usage limits self.daily_api_calls = 0 - self.daily_limit = 50 # Max 50 API calls per day (~$2.50 max cost) + self.daily_limit = 50 self.last_reset_date = None def _get_cached_suggestion(self, text: str) -> WritingSuggestion | None: - """No cached suggestions - always use real API calls for authentic results.""" return None def _check_daily_limit(self) -> bool: - """Check if we're within daily API usage limits.""" import datetime - today = datetime.date.today() - - # Reset counter if it's a new day if self.last_reset_date != today: self.daily_api_calls = 0 self.last_reset_date = today - - # Check if we've exceeded the limit if self.daily_api_calls >= self.daily_limit: return False - - # Increment counter for this API call self.daily_api_calls += 1 logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today") return True - async def suggest(self, text: str, user_id: str | None = None) -> List[WritingSuggestion]: + async def suggest(self, text: str, user_id: str | None = None, cursor_position: Optional[int] = None) -> List[WritingSuggestion]: if not text or len(text.strip()) < 6: return [] @@ -67,26 +59,41 @@ class WritingAssistantService: if len(text.strip()) < 50: return [] - # 1) Find relevant sources via Exa - sources = await self._search_sources(text, user_id=user_id) + # Use text before cursor for context (where the user is actively writing) + if cursor_position is not None and 0 < cursor_position <= len(text): + context_text = text[:cursor_position] + else: + context_text = text - # 2) Generate continuation suggestion via LLM grounded in sources - suggestion_text, confidence = await self._generate_continuation(text, sources, user_id=user_id) + # 1) Find relevant sources via Exa (non-fatal) + sources = [] + try: + sources = await self._search_sources(context_text, user_id=user_id) + except Exception as e: + logger.warning(f"WritingAssistant Exa search failed, proceeding without sources: {e}") + + # 2) Generate continuation suggestion via LLM + suggestion_text, confidence = await self._generate_continuation(context_text, sources, user_id=user_id) if not suggestion_text: return [] return [WritingSuggestion(text=suggestion_text.strip(), confidence=confidence, sources=sources)] - async def _search_sources(self, text: str, user_id: str = None) -> List[Dict[str, Any]]: - """Search for relevant sources using ExaResearchProvider with subscription checks.""" + async def _search_sources(self, context_text: str, user_id: str = None) -> List[Dict[str, Any]]: + """Search Exa using the last sentence before cursor for a focused query.""" try: from services.blog_writer.research.exa_provider import ExaResearchProvider - exa_query = ( - (text[-1000:] if len(text) > 1000 else text) - + "\n\nIf you found the above interesting, here's another useful resource to read:" - ) + # Extract the last sentence from context to use as a focused search query + sentences = re.split(r'(?<=[.!?])\s+', context_text.strip()) + last_sentence = sentences[-1].strip().strip('"').strip("'") if sentences else context_text + + # If very short, use last two sentences + if len(last_sentence) < 20 and len(sentences) >= 2: + last_sentence = ' '.join(s[-2:]).strip().strip('"').strip("'") + + exa_query = last_sentence[:500] if len(last_sentence) > 500 else last_sentence provider = ExaResearchProvider() sources = await provider.simple_search( @@ -95,7 +102,6 @@ class WritingAssistantService: user_id=user_id, ) - # Normalize keys to match expected format normalized = [] for s in sources: normalized.append({ @@ -104,7 +110,7 @@ class WritingAssistantService: "text": s.get("text", ""), "author": s.get("author", ""), "published_date": s.get("publishedDate", ""), - "score": float(s.get("score", 0.5)), + "score": float(s.get("score") if s.get("score") is not None else 0.5), }) if not normalized: @@ -151,8 +157,21 @@ class WritingAssistantService: suggestion = (str(ai_resp or "")).strip() if not suggestion: raise Exception("Assistive writer returned empty suggestion") - confidence = 0.7 - return suggestion, confidence + + # Dynamic confidence based on source quality and response signals + confidence = 0.5 + if sources: + # More sources and higher scores = more confident + avg_score = sum(s.get("score", 0.5) for s in sources) / len(sources) + confidence = 0.5 + (len(sources) / 6.0) * 0.3 + avg_score * 0.2 + if suggestion.endswith(('.', '!', '?')): + confidence += 0.05 + # Check if citation hint was included + if '[http' in suggestion or '((' in suggestion: + confidence += 0.05 + confidence = min(confidence, 1.0) + + return suggestion, round(confidence, 2) except Exception as e: logger.error(f"WritingAssistant _generate_continuation error: {e}") raise diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2493cb83..a4c3a397 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -73,22 +73,14 @@ export const getApiUrl = () => { throw new Error('REACT_APP_API_URL environment variable is required for production. Please set it in your Vercel project settings.'); } - if (isProduction) { + // Always respect REACT_APP_API_URL if explicitly set — behavior is independent of + // whether the browser is on localhost, ngrok, or any other hostname. + if (apiUrl) { return apiUrl; } - // In development, use localhost by default - const envUrl = process.env.REACT_APP_API_URL; - const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost'; - const isNgrok = envUrl && envUrl.includes('ngrok'); - if (isLocalhost) { - if (isNgrok) { - console.warn('[apiClient] āš ļø Overriding ngrok API URL in dev; using http://localhost:8000 to avoid CORS.'); - } - return 'http://localhost:8000'; - } - // Non-localhost dev (rare): use env if provided, otherwise localhost - return envUrl || 'http://localhost:8000'; + // Development fallback when no env var is configured + return 'http://localhost:8000'; }; // Create a shared axios instance for all API calls diff --git a/frontend/src/api/gscBrainstorm.ts b/frontend/src/api/gscBrainstorm.ts index 0a2678ed..ac0e7a3c 100644 --- a/frontend/src/api/gscBrainstorm.ts +++ b/frontend/src/api/gscBrainstorm.ts @@ -6,20 +6,56 @@ export interface ContentOpportunity { opportunity: string; potential_impact: 'High' | 'Medium'; current_position: number; + current_ctr: number; impressions: number; + clicks: number; + estimated_traffic_gain: number; priority: 'High' | 'Medium'; + suggested_format: string; } export interface KeywordGap { keyword: string; position: number; impressions: number; + current_ctr: number; + clicks: number; + estimated_traffic_if_page1: number; + gap_from_page1: number; +} + +export interface QuickWin { + keyword: string; + position: number; + impressions: number; + current_ctr: number; + clicks: number; + estimated_traffic_gain: number; + reason: string; +} + +export interface PageOpportunity { + page: string; + page_title: string; + impressions: number; + clicks: number; + current_ctr: number; + current_position: number; + reason: string; +} + +export interface AIRecommendation { + title: string; + keyword: string; + reason: string; + format: string; + estimated_impact: string; } export interface AIRecommendations { - immediate_opportunities: string[]; - content_strategy: string[]; - long_term_strategy: string[]; + immediate_opportunities: AIRecommendation[]; + content_strategy: AIRecommendation[]; + long_term_strategy: AIRecommendation[]; } export interface BrainstormSummary { @@ -30,20 +66,24 @@ export interface BrainstormSummary { total_clicks: number; avg_ctr: number; avg_position: number; + ctr_vs_benchmark: number; + health_score: number; keyword_distribution: { positions_1_3: number; positions_4_10: number; positions_11_20: number; positions_21_plus: number; }; - top_keywords: Array<{ keyword: string; impressions: number; position: number }>; - top_pages: Array<{ page: string; clicks: number; impressions: number }>; + top_keywords: Array<{ keyword: string; impressions: number; clicks: number; position: number; ctr: number }>; + top_pages: Array<{ page: string; clicks: number; impressions: number; ctr: number }>; } export interface BrainstormResult { error?: string; content_opportunities: ContentOpportunity[]; keyword_gaps: KeywordGap[]; + quick_wins: QuickWin[]; + page_opportunities: PageOpportunity[]; ai_recommendations: AIRecommendations | Record; summary: BrainstormSummary | Record; } diff --git a/frontend/src/components/BlogWriter/BlogWriter.tsx b/frontend/src/components/BlogWriter/BlogWriter.tsx index 3bf98d08..9ad7d758 100644 --- a/frontend/src/components/BlogWriter/BlogWriter.tsx +++ b/frontend/src/components/BlogWriter/BlogWriter.tsx @@ -1,5 +1,5 @@ import React, { useRef, useCallback, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; @@ -9,6 +9,7 @@ import Button from '@mui/material/Button'; import { debug } from '../../utils/debug'; import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar'; import { blogWriterApi } from '../../services/blogWriterApi'; +import { researchCache } from '../../services/researchCache'; import { useClaimFixer } from '../../hooks/useClaimFixer'; import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor'; import { useBlogWriterState } from '../../hooks/useBlogWriterState'; @@ -34,6 +35,7 @@ import { useModalVisibility } from './BlogWriterUtils/useModalVisibility'; import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs'; import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection'; import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents'; +import { useBlogAsset } from '../../hooks/useBlogAsset'; const BlogWriter: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -205,6 +207,8 @@ const BlogWriter: React.FC = () => { // Store navigateToPhase in a ref for use in polling callbacks const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null); + // When true (Re-Content), polling callback skips auto-confirm and SEO navigation + const skipContentAutoConfirmRef = React.useRef(false); // Normalize section keys to match outline IDs when updating from API responses const handleSectionsUpdate = useCallback((newSections: Record) => { @@ -221,6 +225,83 @@ const BlogWriter: React.FC = () => { } }, [outline, setSections]); + // Blog asset persistence (phase-by-phase saving via ContentAsset) + const { + assetId, + createAsset, + updatePhase, + loadAsset, + resetAsset, + } = useBlogAsset(); + // Load blog asset passed via React Router state (from Asset Library) + const location = useLocation(); + const locationState = location.state as { restoreBlogAssetId?: number } | null; + + // Persist last active asset_id across refreshes + const saveLastAssetId = useCallback((id: number) => { + try { localStorage.setItem('blog_last_asset_id', id.toString()); } catch { /* noop */ } + }, []); + + React.useEffect(() => { + const assetIdFromState = locationState?.restoreBlogAssetId; + if (assetIdFromState) { + // Coming from Asset Library — load that specific asset + loadAsset(assetIdFromState).then(loaded => { + if (!loaded) return; + saveLastAssetId(assetIdFromState); + debug.log('[BlogWriter] Loaded blog asset from navigation state', { asset_id: assetIdFromState, phase: loaded.phase }); + }); + } else { + // No navigation state — try restoring last active asset from localStorage + const savedId = (() => { try { return localStorage.getItem('blog_last_asset_id'); } catch { return null; } })(); + if (savedId) { + const id = parseInt(savedId, 10); + if (!isNaN(id)) { + loadAsset(id).then(loaded => { + if (loaded) { + debug.log('[BlogWriter] Restored last active blog', { asset_id: id, phase: loaded.phase }); + } else { + // Asset was deleted or inaccessible — clear stale localStorage key + try { localStorage.removeItem('blog_last_asset_id'); } catch { /* noop */ } + } + }); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Create/get blog asset before research starts (saves to Asset Library immediately) + const handleBeforeResearchSubmit = useCallback(async (keywords: string, blogLength: string) => { + const id = await createAsset(keywords, keywords, parseInt(blogLength)); + if (id) saveLastAssetId(id); + }, [createAsset, saveLastAssetId]); + + // Wrap handlers to also update the blog ContentAsset + const wrappedHandleResearchComplete = useCallback((researchData: any) => { + handleResearchComplete(researchData); + if (assetId) { updatePhase('research', researchData); saveLastAssetId(assetId); } + }, [handleResearchComplete, assetId, updatePhase, saveLastAssetId]); + + const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => { + handleSEOAnalysisComplete(analysis); + if (assetId) { updatePhase('seo', analysis); saveLastAssetId(assetId); } + }, [handleSEOAnalysisComplete, assetId, updatePhase, saveLastAssetId]); + + const wrappedHandleOutlineConfirmed = useCallback(() => { + handleOutlineConfirmed(); + if (assetId) { + updatePhase('outline', { outline, selected_title: selectedTitle, title_options: titleOptions }); + saveLastAssetId(assetId); + } + }, [handleOutlineConfirmed, assetId, updatePhase, outline, selectedTitle, titleOptions, saveLastAssetId]); + + const wrappedConfirmBlogContent = useCallback(() => { + const result = confirmBlogContent(); + if (assetId) { updatePhase('content', sections); saveLastAssetId(assetId); } + return result; + }, [confirmBlogContent, assetId, updatePhase, sections, saveLastAssetId]); + // Polling hooks - extracted to useBlogWriterPolling const { researchPolling, @@ -231,7 +312,7 @@ const BlogWriter: React.FC = () => { outlinePollingState, mediumPollingState, } = useBlogWriterPolling({ - onResearchComplete: handleResearchComplete, + onResearchComplete: wrappedHandleResearchComplete, onOutlineComplete: handleOutlineComplete, onOutlineError: handleOutlineError, onSectionsUpdate: handleSectionsUpdate, @@ -239,6 +320,10 @@ const BlogWriter: React.FC = () => { debug.log('[BlogWriter] Content generation completed - auto-confirming content'); setContentConfirmed(true); }, + onContentError: () => { + debug.log('[BlogWriter] Content generation failed - reverting outline confirmation'); + setOutlineConfirmed(false); + }, navigateToPhase: (phase) => { debug.log('[BlogWriter] Navigating to phase after content generation', { phase }); // Use ref to access navigateToPhase (defined later in component) @@ -248,6 +333,7 @@ const BlogWriter: React.FC = () => { }, 0); } }, + skipContentAutoConfirmRef, }); // Modal visibility management - extracted to useModalVisibility @@ -304,11 +390,13 @@ const BlogWriter: React.FC = () => { const handlePhaseClick = useCallback((phaseId: string) => { navigateToPhase(phaseId); - // When clicking Research phase, ensure we navigate to research phase (this will trigger research form to show) - if (phaseId === 'research' && !research) { - debug.log('[BlogWriter] Research phase clicked - navigating to research phase to show form'); - // navigateToPhase already called above, which will set currentPhase to 'research' - // BlogWriterLandingSection will detect currentPhase === 'research' and show ManualResearchForm + if (phaseId === 'research') { + if (!currentPhase) { + setResearch(null); + debug.log('[BlogWriter] Research phase clicked from landing - cleared research to show form'); + } else { + debug.log('[BlogWriter] Research phase clicked - showing existing research data'); + } } if (phaseId === 'seo') { if (seoAnalysis) { @@ -318,7 +406,7 @@ const BlogWriter: React.FC = () => { runSEOAnalysisDirect(); } } - }, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]); + }, [navigateToPhase, currentPhase, seoAnalysis, research, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]); const handleNewBlog = useCallback(() => { setResearch(null); @@ -339,12 +427,16 @@ const BlogWriter: React.FC = () => { localStorage.removeItem('blogwriter_user_selected_phase'); localStorage.removeItem('blog_content_confirmed'); localStorage.removeItem('blog_seo_recommendations_applied'); + localStorage.removeItem('blog_last_asset_id'); } catch { // ignore localStorage errors } + researchCache.clearCache(); + resetAsset(); + setSearchParams({}, { replace: true }); }, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata, setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions, - setCurrentPhase]); + setCurrentPhase, resetAsset, setSearchParams]); // Handle ?new=true query param from "New Blog" button in Asset Library React.useEffect(() => { @@ -354,12 +446,12 @@ const BlogWriter: React.FC = () => { } }, [searchParams, handleNewBlog, setSearchParams]); + const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false); + const handleMyBlogs = useCallback(() => { navigate('/asset-library?source_module=blog_writer&asset_type=text'); }, [navigate]); - const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false); - const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0); const confirmNewBlog = useCallback(() => { @@ -401,6 +493,7 @@ const BlogWriter: React.FC = () => { selectedTitle, contentConfirmed, sections, + seoAnalysis, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, @@ -411,7 +504,8 @@ const BlogWriter: React.FC = () => { setIsSEOAnalysisModalOpen, setIsSEOMetadataModalOpen, runSEOAnalysisDirect, - onResearchComplete: handleResearchComplete, + skipContentAutoConfirmRef, + onResearchComplete: wrappedHandleResearchComplete, onOutlineComplete: handleCachedOutlineComplete, onContentComplete: handleCachedContentComplete, }); @@ -433,7 +527,7 @@ const BlogWriter: React.FC = () => { isSEOAnalysisModalOpen, lastSEOModalOpenRef, runSEOAnalysisDirect, - confirmBlogContent, + confirmBlogContent: wrappedConfirmBlogContent, sections, research, openSEOMetadata: () => setIsSEOMetadataModalOpen(true), @@ -461,11 +555,11 @@ const BlogWriter: React.FC = () => { outlineConfirmed={outlineConfirmed} sections={sections} selectedTitle={selectedTitle} - onResearchComplete={handleResearchComplete} + onResearchComplete={wrappedHandleResearchComplete} onOutlineCreated={setOutline} onOutlineUpdated={setOutline} onTitleOptionsSet={setTitleOptions} - onOutlineConfirmed={handleOutlineConfirmed} + onOutlineConfirmed={wrappedHandleOutlineConfirmed} onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')} onMediumGenerationStarted={handleMediumGenerationStarted} onMediumGenerationTriggered={handleMediumGenerationTriggered} @@ -516,10 +610,21 @@ const BlogWriter: React.FC = () => { buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim} applyClaimFix={applyClaimFix} /> - { + if (assetId) { + const fullContent = buildFullMarkdown(); + updatePhase('publish', { + published_at: new Date().toISOString(), + content_preview: fullContent.substring(0, 500), + title: selectedTitle || seoMetadata?.seo_title || '', + }); + saveLastAssetId(assetId); + } + }} /> {/* Phase navigation header - always visible as default interface */} @@ -540,7 +645,7 @@ const BlogWriter: React.FC = () => { hasResearch={!!research} hasOutline={outline.length > 0} outlineConfirmed={outlineConfirmed} - hasContent={Object.keys(sections).length > 0} + hasContent={hasContent} contentConfirmed={contentConfirmed} hasSEOAnalysis={!!seoAnalysis} seoRecommendationsApplied={seoRecommendationsApplied} @@ -557,7 +662,8 @@ const BlogWriter: React.FC = () => { copilotKitAvailable={copilotKitAvailable} currentPhase={currentPhase} navigateToPhase={navigateToPhase} - onResearchComplete={handleResearchComplete} + onResearchComplete={wrappedHandleResearchComplete} + onBeforeResearchSubmit={handleBeforeResearchSubmit} restoreAttempted={restoreAttempted} /> @@ -592,7 +698,7 @@ const BlogWriter: React.FC = () => { onTitleSelect={handleTitleSelect} onCustomTitle={handleCustomTitle} copilotKitAvailable={copilotKitAvailable} - onResearchComplete={handleResearchComplete} + onResearchComplete={wrappedHandleResearchComplete} onOutlineGenerationStart={(taskId) => { setOutlineTaskId(taskId); outlinePolling.startPolling(taskId); @@ -628,7 +734,7 @@ const BlogWriter: React.FC = () => { blogTitle={selectedTitle} researchData={research} onApplyRecommendations={handleApplySeoRecommendations} - onAnalysisComplete={handleSEOAnalysisComplete} + onAnalysisComplete={wrappedHandleSEOAnalysisComplete} /> {/* SEO Metadata Modal */} diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/BlogWriterLandingSection.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/BlogWriterLandingSection.tsx index ffdba88d..24eb7782 100644 --- a/frontend/src/components/BlogWriter/BlogWriterUtils/BlogWriterLandingSection.tsx +++ b/frontend/src/components/BlogWriter/BlogWriterUtils/BlogWriterLandingSection.tsx @@ -9,6 +9,7 @@ interface BlogWriterLandingSectionProps { currentPhase: string; navigateToPhase: (phase: string) => void; onResearchComplete: (research: any) => void; + onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise; restoreAttempted?: boolean; } @@ -20,11 +21,12 @@ export const BlogWriterLandingSection: React.FC = currentPhase, navigateToPhase, onResearchComplete, + onBeforeResearchSubmit, restoreAttempted = false, }) => { if (!research) { if (currentPhase === 'research') { - return ; + return ; } if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) { diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterPolling.ts b/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterPolling.ts index 4fdace0a..02909f80 100644 --- a/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterPolling.ts +++ b/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterPolling.ts @@ -6,6 +6,7 @@ import { useRewritePolling, } from '../../../hooks/usePolling'; import { blogWriterCache } from '../../../services/blogWriterCache'; +import { debug } from '../../../utils/debug'; interface UseBlogWriterPollingProps { onResearchComplete: (research: any) => void; @@ -13,7 +14,9 @@ interface UseBlogWriterPollingProps { onOutlineError: (error: any) => void; onSectionsUpdate: (sections: Record) => void; onContentConfirmed?: () => void; // Callback when content generation completes + onContentError?: () => void; // Callback when content generation fails navigateToPhase?: (phase: string) => void; // Phase navigation function + skipContentAutoConfirmRef?: React.MutableRefObject; // When true, skip auto-confirm & navigation after content generation } export const useBlogWriterPolling = ({ @@ -22,7 +25,9 @@ export const useBlogWriterPolling = ({ onOutlineError, onSectionsUpdate, onContentConfirmed, + onContentError, navigateToPhase, + skipContentAutoConfirmRef, }: UseBlogWriterPollingProps) => { // Research polling hook (for context awareness) - uses blog writer endpoint const researchPolling = useBlogWriterResearchPolling({ @@ -47,36 +52,22 @@ export const useBlogWriterPolling = ({ }); onSectionsUpdate(newSections); - // Auto-confirm content and navigate to SEO phase when content generation completes - // This happens when user clicks "Next:Confirm and generate content" - if (onContentConfirmed) { - onContentConfirmed(); - } - if (navigateToPhase) { - navigateToPhase('seo'); - } - - // Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary) - // Backend also saves via save_and_track_text_content; this is a safety net / metadata update - (async () => { - try { - const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi'); - const totalWords = result.sections.reduce( - (sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length), - 0 - ); - await saveBlogToAssetLibrary({ - title: result.title || 'Untitled Blog', - blogType: 'medium', - wordCount: totalWords, - sectionCount: result.sections?.length, - model: result.model, - generationTimeMs: result.generation_time_ms, - }); - } catch (assetError) { - console.error('[BlogWriter] Failed to save blog to asset library:', assetError); + // Skip auto-confirm and navigation when Re-Content was used + // (user already had content and chose to regenerate — stay on content phase to review) + const skipAutoConfirm = skipContentAutoConfirmRef?.current === true; + if (skipContentAutoConfirmRef) skipContentAutoConfirmRef.current = false; // reset flag + if (skipAutoConfirm) { + debug.log('[BlogWriter] Re-Content: skipping auto-confirm and navigation (user stays on content phase)'); + } else { + // Auto-confirm content and navigate to SEO phase when content generation completes + // This happens for initial content generation (first time) + if (onContentConfirmed) { + onContentConfirmed(); } - })(); + if (navigateToPhase) { + navigateToPhase('seo'); + } + } } } catch (e) { console.error('Failed to apply medium generation result:', e); @@ -84,11 +75,12 @@ export const useBlogWriterPolling = ({ }, onError: (err: any) => { console.error('Medium generation failed:', err); + onContentError?.(); const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase(); if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) { setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100); - } else if (errMsg.includes('no valid structured response')) { - setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100); + } else if (errMsg.includes('no valid structured response') || errMsg.includes('parse') || errMsg.includes('json')) { + setTimeout(() => alert('Content generation failed because the AI provider returned an unparseable response. This is usually a temporary issue — please try again.'), 100); } } }); diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/useModalVisibility.ts b/frontend/src/components/BlogWriter/BlogWriterUtils/useModalVisibility.ts index e39f5313..2ca874e3 100644 --- a/frontend/src/components/BlogWriter/BlogWriterUtils/useModalVisibility.ts +++ b/frontend/src/components/BlogWriter/BlogWriterUtils/useModalVisibility.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface UseModalVisibilityProps { mediumPolling: { isPolling: boolean }; @@ -37,16 +37,24 @@ export const useModalVisibility = ({ } }, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]); - // Handle outline modal visibility + // Handle outline modal visibility with proper timeout cleanup + const outlineHideRef = useRef | null>(null); + useEffect(() => { if (outlinePolling.isPolling && !showOutlineModal) { setShowOutlineModal(true); } else if (!outlinePolling.isPolling && showOutlineModal) { - // Add a small delay to ensure user sees completion message - setTimeout(() => { + outlineHideRef.current = setTimeout(() => { setShowOutlineModal(false); + outlineHideRef.current = null; }, 1000); } + return () => { + if (outlineHideRef.current) { + clearTimeout(outlineHideRef.current); + outlineHideRef.current = null; + } + }; }, [outlinePolling.isPolling, showOutlineModal]); return { diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/usePhaseActionHandlers.ts b/frontend/src/components/BlogWriter/BlogWriterUtils/usePhaseActionHandlers.ts index 575627c9..5b11e3f6 100644 --- a/frontend/src/components/BlogWriter/BlogWriterUtils/usePhaseActionHandlers.ts +++ b/frontend/src/components/BlogWriter/BlogWriterUtils/usePhaseActionHandlers.ts @@ -10,6 +10,7 @@ interface UsePhaseActionHandlersProps { selectedTitle: string | null; contentConfirmed: boolean; sections: Record; + seoAnalysis: any; navigateToPhase: (phase: string) => void; handleOutlineConfirmed: () => void; setIsMediumGenerationStarting: (starting: boolean) => void; @@ -20,6 +21,7 @@ interface UsePhaseActionHandlersProps { setIsSEOAnalysisModalOpen: (open: boolean) => void; setIsSEOMetadataModalOpen: (open: boolean) => void; runSEOAnalysisDirect: () => string; + skipContentAutoConfirmRef?: React.MutableRefObject; onResearchComplete?: (research: any) => void; onOutlineComplete?: (outline: any) => void; onContentComplete?: (sections: Record) => void; @@ -31,6 +33,7 @@ export const usePhaseActionHandlers = ({ selectedTitle, contentConfirmed, sections, + seoAnalysis, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, @@ -41,32 +44,14 @@ export const usePhaseActionHandlers = ({ setIsSEOAnalysisModalOpen, setIsSEOMetadataModalOpen, runSEOAnalysisDirect, + skipContentAutoConfirmRef, onResearchComplete, onOutlineComplete, onContentComplete, }: UsePhaseActionHandlersProps) => { const handleResearchAction = useCallback(() => { - if (research) { - navigateToPhase('research'); - return; - } - - const cachedEntries = researchCache.getAllCachedEntries(); - const latestCached = cachedEntries.find(entry => { - try { - return new Date(entry.expires_at) > new Date(); - } catch { - return false; - } - }); - - if (latestCached && onResearchComplete) { - debug.log('[BlogWriter] Restoring cached research data', { keywords: latestCached.keywords }); - onResearchComplete(latestCached.result); - } - navigateToPhase('research'); - }, [navigateToPhase, onResearchComplete, research]); + }, [navigateToPhase]); const handleOutlineAction = useCallback(async () => { if (!research) { @@ -105,7 +90,7 @@ export const usePhaseActionHandlers = ({ const handleContentAction = useCallback(async () => { if (!outline || outline.length === 0) { - alert('Please generate and confirm an outline first.'); + alert('Please generate an outline first.'); return; } if (!research) { @@ -117,22 +102,33 @@ export const usePhaseActionHandlers = ({ // Confirm outline first handleOutlineConfirmed(); - // Check cache first (shared utility) const outlineIds = outline.map(s => String(s.id)); - const cachedContent = blogWriterCache.getCachedContent(outlineIds); + const hasExistingContent = sections && Object.keys(sections).length > 0 && Object.values(sections).some(c => c?.trim()); - if (cachedContent) { - debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length }); - if (onContentComplete) { - onContentComplete(cachedContent); - } - return; + // Signal to polling callback: if content was already confirmed (Re-Content), + // skip auto-confirm and SEO navigation so user stays on content phase to review + if (skipContentAutoConfirmRef && hasExistingContent) { + skipContentAutoConfirmRef.current = true; + debug.log('[BlogWriter] Re-Content: setting skipAutoConfirm flag'); } - // Also check if sections already exist in current state (shared utility) - if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) { - debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length }); - return; + // Only use cache for initial generation (when no content exists yet). + // "Re-Content" label means user explicitly wants to regenerate, so skip cache. + if (!hasExistingContent) { + const cachedContent = blogWriterCache.getCachedContent(outlineIds); + if (cachedContent) { + debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length }); + if (onContentComplete) { + onContentComplete(cachedContent); + } + return; + } + if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) { + debug.log('[BlogWriter] Content already exists in state, skipping generation'); + return; + } + } else { + debug.log('[BlogWriter] Content exists - regenerating per user request'); } // If short/medium blog (<=1000 words), trigger content generation automatically @@ -183,13 +179,17 @@ export const usePhaseActionHandlers = ({ const handleSEOAction = useCallback(() => { if (!contentConfirmed) { - // Mark content as confirmed when SEO action is clicked setContentConfirmed(true); } navigateToPhase('seo'); - runSEOAnalysisDirect(); - debug.log('[BlogWriter] SEO action triggered - running SEO analysis'); - }, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]); + if (seoAnalysis) { + setIsSEOAnalysisModalOpen(true); + debug.log('[BlogWriter] SEO analysis exists - opening modal for review'); + } else { + runSEOAnalysisDirect(); + debug.log('[BlogWriter] SEO action triggered - running SEO analysis'); + } + }, [contentConfirmed, seoAnalysis, setContentConfirmed, navigateToPhase, setIsSEOAnalysisModalOpen, runSEOAnalysisDirect]); const handleApplySEORecommendations = useCallback(() => { navigateToPhase('seo'); diff --git a/frontend/src/components/BlogWriter/BrainstormButton.tsx b/frontend/src/components/BlogWriter/BrainstormButton.tsx index 7dfdf371..12e87399 100644 --- a/frontend/src/components/BlogWriter/BrainstormButton.tsx +++ b/frontend/src/components/BlogWriter/BrainstormButton.tsx @@ -26,8 +26,11 @@ export const BrainstormButton: React.FC = ({ brainstormError, contentOpportunities, keywordGaps, + quickWins, + pageOpportunities, aiRecommendations, summary, + progressMessage, connectGSC, brainstorm, reset, @@ -36,7 +39,6 @@ export const BrainstormButton: React.FC = ({ const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length; const isVisible = wordCount >= 3; - // Auto-trigger brainstorm after GSC connection succeeds useEffect(() => { if (gscConnected && pendingBrainstormRef.current && !isConnecting) { pendingBrainstormRef.current = false; @@ -100,7 +102,7 @@ export const BrainstormButton: React.FC = ({ } style={{ padding: '12px 20px', - backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50', + backgroundColor: disabled || isBrainstorming ? '#999' : '#4caf50', color: 'white', border: 'none', borderRadius: '6px', @@ -144,10 +146,13 @@ export const BrainstormButton: React.FC = ({ }} contentOpportunities={contentOpportunities} keywordGaps={keywordGaps} + quickWins={quickWins} + pageOpportunities={pageOpportunities} aiRecommendations={aiRecommendations} summary={summary} error={brainstormError} isBrainstorming={isBrainstorming} + progressMessage={progressMessage} onSelectSuggestion={handleSelectSuggestion} /> @@ -165,10 +170,6 @@ export const BrainstormButton: React.FC = ({ ); }; -/* ------------------------------------------------------------------ */ -/* GSC Connection Overlay */ -/* ------------------------------------------------------------------ */ - const GSConnectOverlay: React.FC<{ isConnecting: boolean; connectError: string | null; @@ -177,7 +178,6 @@ const GSConnectOverlay: React.FC<{ onSuccess: () => void; onCancel: () => void; }> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => { - // If connection just succeeded, auto-proceed if (gscConnected && !isConnecting) { onSuccess(); return null; diff --git a/frontend/src/components/BlogWriter/GSCBrainstormModal.tsx b/frontend/src/components/BlogWriter/GSCBrainstormModal.tsx index 64acbcff..5381db1e 100644 --- a/frontend/src/components/BlogWriter/GSCBrainstormModal.tsx +++ b/frontend/src/components/BlogWriter/GSCBrainstormModal.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { ContentOpportunity, KeywordGap, + QuickWin, + PageOpportunity, AIRecommendations, + AIRecommendation, BrainstormSummary, } from '../../api/gscBrainstorm'; @@ -11,14 +14,23 @@ interface GSCBrainstormModalProps { onClose: () => void; contentOpportunities: ContentOpportunity[]; keywordGaps: KeywordGap[]; + quickWins: QuickWin[]; + pageOpportunities: PageOpportunity[]; aiRecommendations: AIRecommendations | null; summary: BrainstormSummary | null; error: string | null; isBrainstorming: boolean; + progressMessage?: string; onSelectSuggestion: (keyword: string) => void; } -const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const; +const tabLabels = [ + 'Quick Wins', + 'Opportunities', + 'Keyword Gaps', + 'Pages', + 'AI Recommendations', +] as const; type TabKey = typeof tabLabels[number]; export const GSCBrainstormModal: React.FC = ({ @@ -26,225 +38,223 @@ export const GSCBrainstormModal: React.FC = ({ onClose, contentOpportunities, keywordGaps, + quickWins, + pageOpportunities, aiRecommendations, summary, error, isBrainstorming, + progressMessage, onSelectSuggestion, }) => { - const [activeTab, setActiveTab] = React.useState('Opportunities'); + const [activeTab, setActiveTab] = React.useState('Quick Wins'); if (!open) return null; - const hasNoData = - !isBrainstorming && - !error && - contentOpportunities.length === 0 && - keywordGaps.length === 0 && - !aiRecommendations; + const hasData = + contentOpportunities.length > 0 || + keywordGaps.length > 0 || + quickWins.length > 0 || + pageOpportunities.length > 0 || + aiRecommendations !== null; + + const getTabCount = (tab: TabKey): number => { + switch (tab) { + case 'Quick Wins': return quickWins.length; + case 'Opportunities': return contentOpportunities.length; + case 'Keyword Gaps': return keywordGaps.length; + case 'Pages': return pageOpportunities.length; + case 'AI Recommendations': + return aiRecommendations + ? (aiRecommendations.immediate_opportunities?.length ?? 0) + + (aiRecommendations.content_strategy?.length ?? 0) + + (aiRecommendations.long_term_strategy?.length ?? 0) + : 0; + } + }; return (
e.stopPropagation()} > {/* Header */} -
+
-

+

Brainstorm Topics with GSC Data

- {summary && ( -

+ {summary?.site_url && ( +

{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '} - {summary.total_keywords_analyzed} keywords analyzed + {summary.total_keywords_analyzed} keywords

)}
+ >āœ•
- {/* Summary metrics bar */} + {/* Summary dashboard */} {summary && summary.total_keywords_analyzed > 0 && ( -
- - {summary.total_impressions?.toLocaleString()} impressions - - - {summary.total_clicks?.toLocaleString()} clicks - - - {summary.avg_ctr}% avg CTR - - - {summary.avg_position} avg position - -
+ )} - {/* Loading */} + {/* Loading with educational progress */} {isBrainstorming && ( -
-
- -

- Analyzing your GSC data and generating topic suggestions... -

+
+
+
+
+ +
+
+ {progressMessage ? ( + <> +

+ {progressMessage} +

+
+
+ +
+ + ) : ( +

+ Analyzing your GSC data and generating topic suggestions... +

+ )} +

+ This usually takes 5-15 seconds +

+
+
+

+ What's happening behind the scenes: +

+

+ We fetch your real Google Search Console data, scan for high-ROI keywords, + find pages that need optimization, and ask our AI to craft topic suggestions + tailored to your site's analytics. +

+
)} {/* Error */} {error && !isBrainstorming && ( -
-

- {error} -

-

- Make sure your Google Search Console is connected and has data for the last 30 days. -

+
+
⚠
+

{error}

+

Make sure your Google Search Console is connected and has data for the last 30 days.

)} {/* No data */} - {hasNoData && ( -
-

- No brainstorming data available. Try different keywords or check your GSC connection. -

+ {!isBrainstorming && !error && !hasData && ( +
+
šŸ”
+

No brainstorming data available. Try different keywords or check your GSC connection.

)} {/* Results */} - {!isBrainstorming && !error && !hasNoData && ( + {!isBrainstorming && !error && hasData && ( <> {/* Tabs */} -
+
{tabLabels.map((tab) => { - const count = - tab === 'Opportunities' - ? contentOpportunities.length - : tab === 'Keyword Gaps' - ? keywordGaps.length - : aiRecommendations - ? (aiRecommendations.immediate_opportunities?.length ?? 0) + - (aiRecommendations.content_strategy?.length ?? 0) + - (aiRecommendations.long_term_strategy?.length ?? 0) - : 0; + const count = getTabCount(tab); + const isActive = activeTab === tab; return ( ); @@ -252,48 +262,34 @@ export const GSCBrainstormModal: React.FC = ({
{/* Tab content */} -
- {activeTab === 'Opportunities' && ( - - )} - {activeTab === 'Keyword Gaps' && ( - - )} - {activeTab === 'AI Recommendations' && ( - - )} +
+ {activeTab === 'Quick Wins' && } + {activeTab === 'Opportunities' && } + {activeTab === 'Keyword Gaps' && } + {activeTab === 'Pages' && } + {activeTab === 'AI Recommendations' && }
)} {/* Footer */} -
+
+ Click any keyword or title to use it as your research topic + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'} + >Close
@@ -301,196 +297,326 @@ export const GSCBrainstormModal: React.FC = ({ }; /* ------------------------------------------------------------------ */ -/* Sub-components */ +/* Summary Dashboard */ /* ------------------------------------------------------------------ */ -const OpportunitiesTab: React.FC<{ - opportunities: ContentOpportunity[]; - onSelect: (keyword: string) => void; -}> = ({ opportunities, onSelect }) => { +const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => { + const dist = summary.keyword_distribution || {}; + const total = dist.positions_1_3 + dist.positions_4_10 + dist.positions_11_20 + dist.positions_21_plus || 1; + const healthColor = summary.health_score >= 70 ? '#2e7d32' : summary.health_score >= 40 ? '#f57c00' : '#d32f2f'; + const ctrColor = summary.ctr_vs_benchmark >= 0 ? '#2e7d32' : '#d32f2f'; + + return ( +
+
+ + + + + +
+ {total > 1 && ( +
+ + Rank Distribution + + + + + +
+ )} +
+ ); +}; + +const MetricBox: React.FC<{ + label: string; value: string; valueColor?: string; + sublabel?: string; sublabelColor?: string; driving?: boolean; +}> = ({ label, value, valueColor, sublabel, sublabelColor, driving }) => ( +
+
{value}
+
{label}
+ {sublabel &&
{sublabel}
} +
+); + +const DistBadge: React.FC<{ label: string; count: number; total: number; color: string }> = ({ label, count, total, color }) => ( + + + {label}: {count} ({Math.round(count / total * 100)}%) + +); + +/* ------------------------------------------------------------------ */ +/* Quick Wins Tab */ +/* ------------------------------------------------------------------ */ + +const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void }> = ({ wins, onSelect }) => { + if (wins.length === 0) { + return ; + } + + return ( +
+

+ These keywords are already on page 1. A small optimization push could land them in the top 3 — the highest-ROI opportunities available. +

+ {wins.map((win, i) => ( +
onSelect(win.keyword)} + onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#dcedc8'; e.currentTarget.style.borderLeftColor = '#2e7d32'; }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#f1f8e9'; e.currentTarget.style.borderLeftColor = '#4caf50'; }} + > +
+ {win.keyword} +
+ + +
+
+

{win.reason}

+
+ {win.impressions.toLocaleString()} impressions · {win.current_ctr}% current CTR +
+
+ ))} +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* Opportunities Tab */ +/* ------------------------------------------------------------------ */ + +const OpportunitiesTab: React.FC<{ opportunities: ContentOpportunity[]; onSelect: (kw: string) => void }> = ({ opportunities, onSelect }) => { if (opportunities.length === 0) { return ; } return ( -
+
{opportunities.map((opp, i) => (
onSelect(opp.keyword)} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'} > -
- - {opp.keyword} - -
+
+ {opp.keyword} +
- + + {opp.suggested_format && }
-

- {opp.opportunity} -

-
- {opp.impressions.toLocaleString()} impressions · Position {opp.current_position} +

{opp.opportunity}

+
+ {opp.impressions.toLocaleString()} impressions + Position {opp.current_position} + {opp.current_ctr}% CTR + +{opp.estimated_traffic_gain} clicks/mo potential
))} -

- Click any keyword to use it as your research topic. -

); }; -const GapsTab: React.FC<{ - gaps: KeywordGap[]; - onSelect: (keyword: string) => void; -}> = ({ gaps, onSelect }) => { +/* ------------------------------------------------------------------ */ +/* Keyword Gaps Tab */ +/* ------------------------------------------------------------------ */ + +const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }> = ({ gaps, onSelect }) => { if (gaps.length === 0) { - return ( - - ); + return ; } return ( -
+
+

+ These keywords rank between positions 4-20. Writing targeted content could push them to page 1 where CTR increases dramatically. +

{gaps.map((gap, i) => (
onSelect(gap.keyword)} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'} > - {gap.keyword} -
- Position {gap.position} · {gap.impressions.toLocaleString()} impressions +
+ {gap.keyword} +
+ {gap.current_ctr}% CTR · {gap.clicks} clicks +
+
+
+
Position #{gap.position.toFixed(0)}
+
+{gap.estimated_traffic_if_page1} clicks/mo if page 1
))} -

- These keywords rank between positions 4-20. Writing targeted content could push them to page 1. -

); }; -const AIRecommendationsTab: React.FC<{ - recommendations: AIRecommendations | null; - onSelect: (keyword: string) => void; -}> = ({ recommendations, onSelect }) => { - if (!recommendations) { - return ; +/* ------------------------------------------------------------------ */ +/* Pages Tab */ +/* ------------------------------------------------------------------ */ + +const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => { + if (pages.length === 0) { + return ; } return ( -
- - - +
+

+ These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks. +

+ {pages.map((pg, i) => ( +
+
+ {pg.page_title} + +
+

{pg.reason}

+
+ {pg.impressions.toLocaleString()} impressions · {pg.clicks} clicks · Position {pg.current_position} +
+
{pg.page}
+
+ ))}
); }; -const RecommendationSection: React.FC<{ - title: string; - items: string[]; - onSelect: (keyword: string) => void; - color: string; -}> = ({ title, items, onSelect, color }) => { +/* ------------------------------------------------------------------ */ +/* AI Recommendations Tab */ +/* ------------------------------------------------------------------ */ + +const AIRecommendationsTab: React.FC<{ recommendations: AIRecommendations | null; onSelect: (kw: string) => void }> = ({ recommendations, onSelect }) => { + if (!recommendations) { + return ; + } + + return ( +
+ + + +
+ ); +}; + +const RecommendationSection: React.FC<{ title: string; items: AIRecommendation[]; onSelect: (kw: string) => void; color: string }> = ({ title, items, onSelect, color }) => { if (!items || items.length === 0) return null; return (
-

{title}

-
    +

    + + {title} +

    +
    {items.map((item, i) => ( -
  • { - const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim(); - if (short) onSelect(short); + const kw = item.keyword || item.title.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim(); + if (kw && kw.length > 2) onSelect(kw); }} + onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8faff'; e.currentTarget.style.borderColor = '#c8d8e8'; }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; e.currentTarget.style.borderColor = '#e8e8e8'; }} > - {item} -
  • +
    {item.title}
    + {item.keyword &&
    + Target: {item.keyword} +
    } + {item.reason &&
    {item.reason}
    } +
    + {item.format && {item.format}} + {item.estimated_impact && {item.estimated_impact}} +
    +
    ))} -
+
); }; +/* ------------------------------------------------------------------ */ +/* Shared */ +/* ------------------------------------------------------------------ */ + const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => ( - - {label} - + {label} ); const EmptyMessage: React.FC<{ message: string }> = ({ message }) => ( -
-

{message}

+
+

{message}

); diff --git a/frontend/src/components/BlogWriter/ManualResearchForm.tsx b/frontend/src/components/BlogWriter/ManualResearchForm.tsx index dbb858a2..94642c1c 100644 --- a/frontend/src/components/BlogWriter/ManualResearchForm.tsx +++ b/frontend/src/components/BlogWriter/ManualResearchForm.tsx @@ -6,9 +6,10 @@ import { BrainstormButton } from './BrainstormButton'; interface ManualResearchFormProps { onResearchComplete?: (research: BlogResearchResponse) => void; + onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise; } -export const ManualResearchForm: React.FC = ({ onResearchComplete }) => { +export const ManualResearchForm: React.FC = ({ onResearchComplete, onBeforeResearchSubmit }) => { const [keywords, setKeywords] = useState(''); const [blogLength, setBlogLength] = useState('1000'); @@ -30,6 +31,7 @@ export const ManualResearchForm: React.FC = ({ onResear return; } try { + await onBeforeResearchSubmit?.(trimmed, blogLength); await startResearch(trimmed, blogLength); } catch (err) { alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`); diff --git a/frontend/src/components/BlogWriter/OutlineGenerator.tsx b/frontend/src/components/BlogWriter/OutlineGenerator.tsx index 35c372c7..ed49c6d6 100644 --- a/frontend/src/components/BlogWriter/OutlineGenerator.tsx +++ b/frontend/src/components/BlogWriter/OutlineGenerator.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; import { useCopilotAction } from '@copilotkit/react-core'; import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi'; import { blogWriterCache } from '../../services/blogWriterCache'; @@ -22,6 +22,9 @@ export const OutlineGenerator = forwardRef(({ navigateToPhase, onOutlineCreated }, ref) => { + // Guard against concurrent outline generation (multiple triggers: UI button + CopilotKit action) + const outlineGenInProgressRef = useRef(false); + // Expose an imperative method to trigger outline generation directly (bypass LLM) useImperativeHandle(ref, () => ({ generateNow: async () => { @@ -29,13 +32,16 @@ export const OutlineGenerator = forwardRef(({ return { success: false, message: 'No research yet. Please research a topic first.' }; } + if (outlineGenInProgressRef.current) { + return { success: false, message: 'Outline generation is already in progress.' }; + } + // Check cache first (shared utility) const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || []; const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords); if (cachedOutline) { console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length }); - // Return cached result - caller should handle setting outline state return { success: true, cached: true, @@ -44,6 +50,7 @@ export const OutlineGenerator = forwardRef(({ }; } + outlineGenInProgressRef.current = true; try { onModalShow?.(); const { task_id } = await blogWriterApi.startOutlineGeneration({ research }); @@ -53,6 +60,8 @@ export const OutlineGenerator = forwardRef(({ } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { success: false, message: errorMessage }; + } finally { + outlineGenInProgressRef.current = false; } } })); @@ -65,6 +74,10 @@ export const OutlineGenerator = forwardRef(({ return { success: false, message: 'No research yet. Please research a topic first.' }; } + if (outlineGenInProgressRef.current) { + return { success: false, message: 'Outline generation is already in progress. Please wait...' }; + } + // Check cache first (shared utility) const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || []; const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords); @@ -89,6 +102,7 @@ export const OutlineGenerator = forwardRef(({ }; } + outlineGenInProgressRef.current = true; try { // Navigate to outline phase when outline generation starts navigateToPhase?.('outline'); @@ -129,6 +143,8 @@ export const OutlineGenerator = forwardRef(({ success: false, message: userMessage }; + } finally { + outlineGenInProgressRef.current = false; } }, render: ({ status }: any) => { diff --git a/frontend/src/components/BlogWriter/OutlineProgressModal.tsx b/frontend/src/components/BlogWriter/OutlineProgressModal.tsx index dcc60c6b..5c1a43c7 100644 --- a/frontend/src/components/BlogWriter/OutlineProgressModal.tsx +++ b/frontend/src/components/BlogWriter/OutlineProgressModal.tsx @@ -27,6 +27,8 @@ export const OutlineProgressModal: React.FC = ({ if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) { return 'āš ļø All AI providers are currently unavailable. Please check your API keys or try again later.'; } + + // Outline phase messages if (message.includes('Starting outline generation')) { return '🧩 Starting to create your blog outline...'; } @@ -70,6 +72,28 @@ export const OutlineProgressModal: React.FC = ({ return 'šŸŽ‰ Success! Your personalized blog outline is ready!'; } + // Content generation phase messages + if (message.includes('Alwrity is preparing your blog content')) { + return 'ā³ Alwrity is getting ready to write your blog — this usually takes 20–40 seconds. Your outline and research are being packaged for the AI.'; + } + if (message.includes('Packaging your outline sections and research data')) { + return 'šŸ“¦ Organizing your outline sections, key points, and research data so the AI can write each section with full context.'; + } + if (message.includes('Found existing content in cache')) { + return '⚔ Found previously generated content — loading it instantly so you don\'t have to wait!'; + } + if (message.includes('AI is writing each section with research-backed insights')) { + return 'šŸ¤– AI is writing each section of your blog, weaving in research findings, key points, and maintaining a consistent voice throughout.'; + } + if (message.includes('Polishing content')) { + return '✨ Reviewing and polishing your content — improving sentence flow, paragraph structure, and readability for a professional finish.'; + } + if (message.includes('Content generation complete')) { + return message + .replace('Content generation complete!', 'āœ… Content generation complete!') + .replace('Next up:', '\n\nšŸ“Œ Next phase:'); + } + // Return the original message if no mapping found return message; }; @@ -137,7 +161,9 @@ export const OutlineProgressModal: React.FC = ({ fontWeight: '700', textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)' }}> - {titleOverride || (status === 'complete' ? 'šŸŽ‰ Outline Ready!' : status === 'error' ? 'āŒ Generation Failed' : '🧩 Creating Your Blog Outline')} + {titleOverride + ? (status === 'complete' ? 'šŸŽ‰ Content Ready!' : status === 'error' ? 'āŒ Generation Failed' : 'šŸ“ Generating Your Blog Content') + : (status === 'complete' ? 'šŸŽ‰ Outline Ready!' : status === 'error' ? 'āŒ Generation Failed' : '🧩 Creating Your Blog Outline')} {/* Progress Bar */} @@ -165,15 +191,15 @@ export const OutlineProgressModal: React.FC = ({ }}> {titleOverride ? (status === 'complete' - ? 'Your AI-generated blog content is ready!' + ? 'āœ… Your blog content has been generated! Review it in the editor, then optimize for SEO.' : status === 'error' - ? 'Something went wrong during generation' - : 'AI is generating your blog content...') + ? 'Content generation encountered an issue. You can retry from the content phase.' + : 'Alwrity is writing your blog content using AI...') : (status === 'complete' - ? 'Your AI-powered blog outline is ready to use!' + ? 'āœ… Your blog outline is ready! Review and confirm it, then proceed to generate content.' : status === 'error' - ? 'Something went wrong during outline generation' - : 'AI is analyzing your research and creating the perfect blog structure...')} + ? 'Outline generation encountered an issue. Please try again.' + : 'Alwrity is analyzing your research and building your blog structure...')}

@@ -188,14 +214,21 @@ export const OutlineProgressModal: React.FC = ({ padding: '16px', color: '#dc2626' }}> - Error: {error} +
āŒ Error
+
+ {error.includes('You do not have access') + ? 'You do not have access to the blog writer. Please check your subscription or account permissions.' + : error.includes('balance') + ? 'Your API balance is insufficient. Please top up your account or switch to a different provider.' + : error} +
) : ( <> {/* Current Status */}
= ({
= ({ height: '8px', borderRadius: '50%', backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6', - animation: status === 'executing' ? 'pulse 2s infinite' : 'none' + animation: status === 'running' ? 'pulse 2s infinite' : 'none' }} /> - Current Status + {status === 'complete' ? 'Generation Complete' : 'Current Status'}
- {latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'} + {latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing...'}
@@ -235,7 +269,7 @@ export const OutlineProgressModal: React.FC = ({ margin: '0 0 12px 0', fontSize: '14px', fontWeight: '600', - color: '#374151' + color: '#374151' }}> Progress Timeline diff --git a/frontend/src/components/BlogWriter/PhaseNavigation.tsx b/frontend/src/components/BlogWriter/PhaseNavigation.tsx index f45b2eae..3e1060a5 100644 --- a/frontend/src/components/BlogWriter/PhaseNavigation.tsx +++ b/frontend/src/components/BlogWriter/PhaseNavigation.tsx @@ -43,10 +43,20 @@ const PHASE_TOOLTIPS: Record = { research: 'Research your topic and gather data from the web to create a well-informed blog post.', outline: 'Create and refine your blog outline with AI-generated structure and key talking points.', content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.', - seo: 'Optimize your blog for search engines with AI-powered SEO analysis, recommendations, and metadata.', + seo: 'Optimize your blog for search engines with AI-powered analysis.', publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.', }; +const CONTENT_TOOLTIPS: Record = { + generate: 'Generate blog content from your confirmed outline.', + regenerate: 'Content exists. Click to review or regenerate content.', +}; + +const SEO_TOOLTIPS: Record = { + analyze: 'Run an AI-powered SEO analysis of your blog content. Checks keyword optimization, readability, content structure, and delivers actionable recommendations to improve search rankings. You can then apply recommendations and generate SEO metadata (title tags, meta descriptions, Open Graph tags).', + reanalyze: 'SEO analysis exists. Click to review results, re-analyze your content after edits, apply SEO recommendations to improve your content, or generate SEO metadata (title tags, meta descriptions, Open Graph tags) for better search visibility.', +}; + const PHASE_ACTIONS: Record = { research: 'Enter keywords to research your topic', outline: 'Create your blog outline to structure your content', @@ -91,19 +101,13 @@ export const PhaseNavigation: React.FC = ({ } break; case 'content': - if (hasOutline && !outlineConfirmed) { - return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null }; + if (hasOutline) { + return { label: hasContent ? 'Re-Content' : 'Generate Content', handler: actionHandlers.onContentAction || null }; } break; case 'seo': - if (hasContent && !hasSEOAnalysis) { - return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null }; - } - if (hasSEOAnalysis && !seoRecommendationsApplied) { - return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null }; - } - if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) { - return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null }; + if (hasContent) { + return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null }; } break; case 'publish': @@ -202,8 +206,9 @@ export const PhaseNavigation: React.FC = ({ /* Show action button only when phase is NOT completed. Research action: only on landing page (not current), to invite start. - Other phase actions: show when current, pending, or next-actionable. */ - const showAction = action.handler && !isDone && ( + Other phase actions: show when current, pending, or next-actionable. + Content and SEO phases use only the chip (no separate action button). */ + const showAction = action.handler && !isDone && phase.id !== 'content' && phase.id !== 'seo' && ( (!isCurrent && phase.id === 'research' && !hasResearch) || (isCurrent && phase.id !== 'research') || (!isCurrent && !isDisabled && phase.id !== 'research') || @@ -328,11 +333,17 @@ export const PhaseNavigation: React.FC = ({ - {phase.name} + + {phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name} + {isDisabled ? `Complete the previous phase first to unlock ${phase.name}.` - : (PHASE_TOOLTIPS[phase.id] || phase.description)} + : (phase.id === 'content' + ? (hasContent ? CONTENT_TOOLTIPS.regenerate : CONTENT_TOOLTIPS.generate) + : (phase.id === 'seo' + ? (hasSEOAnalysis ? SEO_TOOLTIPS.reanalyze : SEO_TOOLTIPS.analyze) + : (PHASE_TOOLTIPS[phase.id] || phase.description)))} } @@ -347,7 +358,7 @@ export const PhaseNavigation: React.FC = ({ sx={chipSx} > {phase.icon} - {phase.name} + {phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name} {isDone && ( āœ“ )} diff --git a/frontend/src/components/BlogWriter/Publisher.tsx b/frontend/src/components/BlogWriter/Publisher.tsx index e810c4a5..97253f28 100644 --- a/frontend/src/components/BlogWriter/Publisher.tsx +++ b/frontend/src/components/BlogWriter/Publisher.tsx @@ -10,6 +10,7 @@ interface PublisherProps { buildFullMarkdown: () => string; convertMarkdownToHTML: (md: string) => string; seoMetadata: BlogSEOMetadataResponse | null; + onPublishComplete?: () => void; } const saveCompleteBlogAsset = async ( @@ -37,7 +38,8 @@ const useCopilotActionTyped = useCopilotAction as any; export const Publisher: React.FC = ({ buildFullMarkdown, convertMarkdownToHTML, - seoMetadata + seoMetadata, + onPublishComplete, }) => { const { publishToWix, @@ -87,6 +89,7 @@ export const Publisher: React.FC = ({ md, seoMetadata ); + onPublishComplete?.(); } return wixResult; } else if (platform === 'wordpress') { @@ -137,6 +140,7 @@ export const Publisher: React.FC = ({ if (result.success) { saveCompleteBlogAsset(title, md, seoMetadata); + onPublishComplete?.(); return { success: true, url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`, diff --git a/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx b/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx index ed0fdc99..1a0c93ca 100644 --- a/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx +++ b/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx @@ -241,29 +241,41 @@ export const SEOAnalysisModal: React.FC = ({ console.log('šŸ”„ Force refresh requested, skipping cache check'); } - setProgressMessage('Starting SEO analysis...'); - - // Simulated progress - 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)); - } - - // Backend call - const response = await apiClient.post('/api/blog-writer/seo/analyze', { + // Backend call — run concurrently with progress simulation + // Use longer timeout (120s) since SEO analysis can take 40-60s + const responsePromise = apiClient.post('/api/blog-writer/seo/analyze', { blog_content: blogContent, blog_title: blogTitle, research_data: researchData - }); + }, { timeout: 120000 }); + + // Simulated progress runs alongside the API call to keep the user informed. + // Each stage.at is cumulative ms from start. Cancelled when the API returns. + let progressCancelled = false; + const progressStages = [ + { at: 2000, progress: 10, message: 'Extracting keywords from research data...' }, + { at: 8000, progress: 25, message: 'Analyzing content structure and readability...' }, + { at: 20000, progress: 40, message: 'Evaluating heading hierarchy and flow...' }, + { at: 35000, progress: 55, message: 'Checking keyword density and optimization...' }, + { at: 50000, progress: 70, message: 'Generating AI-powered SEO insights...' }, + { at: 65000, progress: 85, message: 'Compiling analysis results and recommendations...' }, + ]; + + (async () => { + const startTime = Date.now(); + for (const stage of progressStages) { + if (progressCancelled) return; + const elapsed = Date.now() - startTime; + const wait = Math.max(0, stage.at - elapsed); + if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait)); + if (progressCancelled) return; + setProgress(stage.progress); + setProgressMessage(stage.message); + } + })(); + + const response = await responsePromise; + progressCancelled = true; const result = response.data; console.log('šŸ” Backend SEO Analysis Response:', result); diff --git a/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx b/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx index 75e817f6..ef20273f 100644 --- a/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx +++ b/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx @@ -107,8 +107,9 @@ const BlogSection: React.FC = ({ const handleContentChange = (e: any) => { const newContent = e.target.value; + const cursorPos = e.target.selectionStart; setContent(newContent); - assistiveWriting.handleTypingChange(newContent); + assistiveWriting.handleTypingChange(newContent, cursorPos); }; const handleFocus = () => setIsFocused(true); diff --git a/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx b/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx index 0cdd7b6c..f08052d0 100644 --- a/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx +++ b/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx @@ -61,7 +61,7 @@ const useBlogTextSelectionHandler = ( } }, 2000); // Update every 2 seconds - // Set a timeout for the fact check (30 seconds) + // Set a timeout for the fact check (120 seconds) const timeoutId = setTimeout(() => { console.log('šŸ” [BlogTextSelectionHandler] Fact check timeout reached'); clearInterval(progressInterval); @@ -76,9 +76,9 @@ const useBlogTextSelectionHandler = ( refuted_claims: 0, insufficient_claims: 0, timestamp: new Date().toISOString(), - error: 'Fact check timed out after 30 seconds. Please try again with shorter text.' + error: 'Fact check timed out after 120 seconds. Please try again with shorter text.' }); - }, 30000); // 30 second timeout + }, 120000); // 120 second timeout try { console.log('šŸ” [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...'); @@ -219,6 +219,27 @@ const useBlogTextSelectionHandler = ( }; + // Close selection menu when clicking outside any selection menu + useEffect(() => { + if (!selectionMenu) return; + + const handleGlobalClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('[data-selection-menu]') && !target.closest('[data-fact-check-results]')) { + setSelectionMenu(null); + } + }; + + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleGlobalClick); + }, 0); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleGlobalClick); + }; + }, [selectionMenu]); + // Cleanup progress and timeouts on unmount useEffect(() => { return () => { diff --git a/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx b/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx index 3ef19dad..fc00c23b 100644 --- a/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx +++ b/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { debug } from '../../../utils/debug'; +import { assistiveWritingApi } from '../../../services/blogWriterApi'; interface SmartTypingAssistProps { contentRef: React.RefObject; @@ -47,6 +48,8 @@ const useSmartTypingAssist = ( const hasShownFirstRef = useRef(false); const isGeneratingRef = useRef(false); const smartSuggestionRef = useRef(null); + const initialContentLengthRef = useRef(null); + const mountedRef = useRef(true); // Quality improvement tracking const [suggestionStats, setSuggestionStats] = useState({ @@ -57,8 +60,8 @@ const useSmartTypingAssist = ( }); // Smart typing assist functionality - const generateSmartSuggestion = async (currentText: string) => { - debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length }); + const generateSmartSuggestion = async (currentText: string, cursorPosition?: number) => { + debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length, cursorPosition }); if (currentText.length < 20) { debug.log('[SmartTypingAssist] Text too short for suggestion'); @@ -70,57 +73,61 @@ const useSmartTypingAssist = ( isGeneratingRef.current = true; try { - // Import the assistive writing API - const { assistiveWritingApi } = await import('../../../services/blogWriterApi'); + if (!mountedRef.current) return; debug.log('[SmartTypingAssist] Calling assistive writing API...'); - const response = await assistiveWritingApi.getSuggestion(currentText); + const response = await assistiveWritingApi.getSuggestion(currentText, cursorPosition); - if (response.success && response.suggestions.length > 0) { - debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length }); + if (!mountedRef.current) return; + + if (!response.success || !response.suggestions.length) { + debug.log('[SmartTypingAssist] No suggestions from API', { message: response.message }); + return; + } + + debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length }); + + // Store all suggestions + setAllSuggestions(response.suggestions); + setSuggestionIndex(0); + + // Show first suggestion + const firstSuggestion = response.suggestions[0]; + debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' }); + + // Track suggestion shown + setSuggestionStats(prev => ({ + ...prev, + totalShown: prev.totalShown + 1 + })); + + // Get viewport-safe position for suggestion placement + if (contentRef.current) { + const element = contentRef.current; + const rect = element.getBoundingClientRect(); + const maxWidth = 420; + const maxHeight = 350; - // Store all suggestions - setAllSuggestions(response.suggestions); - setSuggestionIndex(0); + let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20))); + let y = rect.bottom + 10; - // Show first suggestion - const firstSuggestion = response.suggestions[0]; - debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' }); - - // Track suggestion shown - setSuggestionStats(prev => ({ - ...prev, - totalShown: prev.totalShown + 1 - })); - - // Get viewport-safe position for suggestion placement - if (contentRef.current) { - const element = contentRef.current; - const rect = element.getBoundingClientRect(); - const maxWidth = 420; - const maxHeight = 350; - - let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20))); - let y = rect.bottom + 10; - - if (y + maxHeight > window.innerHeight - 20) { - y = rect.top - maxHeight - 10; - if (y < 20) { - y = Math.max(20, (window.innerHeight - maxHeight) / 2); - x = Math.max(20, (window.innerWidth - maxWidth) / 2); - } + if (y + maxHeight > window.innerHeight - 20) { + y = rect.top - maxHeight - 10; + if (y < 20) { + y = Math.max(20, (window.innerHeight - maxHeight) / 2); + x = Math.max(20, (window.innerWidth - maxWidth) / 2); } - - y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20)); - x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20)); - - setSmartSuggestion({ - text: firstSuggestion.text, - position: { x, y }, - confidence: firstSuggestion.confidence, - sources: firstSuggestion.sources - }); } + + y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20)); + x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20)); + + setSmartSuggestion({ + text: firstSuggestion.text, + position: { x, y }, + confidence: firstSuggestion.confidence, + sources: firstSuggestion.sources + }); } } catch (error) { debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error); @@ -130,24 +137,34 @@ const useSmartTypingAssist = ( } }; - const handleTypingChange = (newText: string) => { + const handleTypingChange = (newText: string, cursorPosition?: number) => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } - setSmartSuggestion(null); + // Track initial content baseline on first user keystroke + // This prevents triggering suggestions on pre-filled content + if (initialContentLengthRef.current === null) { + initialContentLengthRef.current = newText.length; + debug.log('[SmartTypingAssist] Set initial content baseline', { length: newText.length }); + } + + // Store cursor position for use after debounce + const cursorPos = cursorPosition; typingTimeoutRef.current = setTimeout(() => { const cooldownMs = 15000; const now = Date.now(); const sinceLast = now - lastGeneratedAtRef.current; + const baseline = initialContentLengthRef.current ?? 0; + const userAddedChars = newText.length - baseline; - if (!hasShownFirstRef.current && newText.length > 50 && !isGeneratingRef.current) { + if (!hasShownFirstRef.current && newText.length >= 50 && userAddedChars >= 30 && !isGeneratingRef.current) { debug.log('[SmartTypingAssist] Generating first suggestion'); - generateSmartSuggestion(newText); + generateSmartSuggestion(newText, cursorPos); setHasShownFirstSuggestion(true); lastGeneratedAtRef.current = now; - } else if (hasShownFirstRef.current && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) { + } else if (hasShownFirstRef.current && newText.length > 100 && userAddedChars >= 30 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) { debug.log('[SmartTypingAssist] Showing "Continue writing" prompt'); setShowContinueWritingPrompt(true); } @@ -241,11 +258,14 @@ const useSmartTypingAssist = ( const element = contentRef.current as HTMLTextAreaElement; const currentContent = element.value || ''; + const cursorPos = element.selectionStart; setShowContinueWritingPrompt(false); - if (currentContent.length > 20) { - await generateSmartSuggestion(currentContent); + const baseline = initialContentLengthRef.current ?? 0; + const userAddedChars = currentContent.length - baseline; + if (currentContent.length > 20 && userAddedChars >= 10) { + await generateSmartSuggestion(currentContent, cursorPos); } }; @@ -274,9 +294,11 @@ const useSmartTypingAssist = ( useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]); useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]); - // Cleanup timeouts on unmount + // Mount guard and cleanup useEffect(() => { + mountedRef.current = true; return () => { + mountedRef.current = false; if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } diff --git a/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx b/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx index 09e1f1bd..041edb67 100644 --- a/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx +++ b/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx @@ -73,6 +73,7 @@ const TextSelectionMenu: React.FC = ({ {/* Text Selection Menu */} {selectionMenu && (
{ console.log('šŸ” [TextSelectionMenu] Selection menu clicked!', e.target); e.stopPropagation(); @@ -497,6 +498,27 @@ const TextSelectionMenu: React.FC = ({ }}> "{smartSuggestion.text}"
+ + {smartSuggestion.sources && smartSuggestion.sources.length > 0 && ( +
+
+ Sources: +
+ {smartSuggestion.sources.slice(0, 2).map((src, i) => ( + + ))} +
+ )}
{ setAnchorEl({ ...anchorEl, [assetId]: null }); }; + const handleOpenBlogAsset = async (asset: ContentAsset) => { + navigate('/blog-writer', { state: { restoreBlogAssetId: asset.id } }); + }; + const handleRestoreResearchProject = async (asset: ContentAsset) => { try { const projectId = asset.asset_metadata?.project_id; @@ -685,6 +689,7 @@ export const AssetLibrary: React.FC = () => { onShare={handleShare} onDelete={handleDelete} onRestore={handleRestoreResearchProject} + onOpenBlogAsset={handleOpenBlogAsset} /> ))} diff --git a/frontend/src/components/ImageStudio/AssetLibraryComponents/AssetCard.tsx b/frontend/src/components/ImageStudio/AssetLibraryComponents/AssetCard.tsx index fd33139f..e042cadd 100644 --- a/frontend/src/components/ImageStudio/AssetLibraryComponents/AssetCard.tsx +++ b/frontend/src/components/ImageStudio/AssetLibraryComponents/AssetCard.tsx @@ -33,6 +33,7 @@ interface AssetCardProps { onShare: (asset: ContentAsset) => void; onDelete: (id: number) => void; onRestore: (asset: ContentAsset) => void; + onOpenBlogAsset?: (asset: ContentAsset) => void; } export const AssetCard: React.FC = ({ @@ -44,6 +45,7 @@ export const AssetCard: React.FC = ({ onShare, onDelete, onRestore, + onOpenBlogAsset, }) => { return ( = ({ )} + {/* Open Blog Asset button for blog_writer text assets */} + {asset.source_module === 'blog_writer' && asset.asset_type === 'text' && onOpenBlogAsset && ( + + onOpenBlogAsset(asset)} + sx={{ color: '#3b82f6' }} + > + āœļø + + + )} onDownload(asset)} diff --git a/frontend/src/hooks/useGSCBrainstorm.ts b/frontend/src/hooks/useGSCBrainstorm.ts index 1c64dcd0..4b811287 100644 --- a/frontend/src/hooks/useGSCBrainstorm.ts +++ b/frontend/src/hooks/useGSCBrainstorm.ts @@ -1,11 +1,14 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useAuth } from '@clerk/clerk-react'; import { gscBrainstormAPI, BrainstormResult, ContentOpportunity, KeywordGap, + QuickWin, + PageOpportunity, AIRecommendations, + AIRecommendation, BrainstormSummary, } from '../api/gscBrainstorm'; import { useGSCBrainstormConnection } from './useGSCBrainstormConnection'; @@ -20,13 +23,27 @@ interface UseGSCBrainstormReturn { brainstormResult: BrainstormResult | null; contentOpportunities: ContentOpportunity[]; keywordGaps: KeywordGap[]; + quickWins: QuickWin[]; + pageOpportunities: PageOpportunity[]; aiRecommendations: AIRecommendations | null; summary: BrainstormSummary | null; connectGSC: () => Promise; brainstorm: (keywords: string, siteUrl?: string) => Promise; reset: () => void; + progressMessage: string; } +const PROGRESS_MESSAGES = [ + 'Fetching your Google Search Console data for the last 30 days...', + 'Analyzing which keywords bring traffic to your site and which ones need work...', + 'Scanning for quick wins — keywords already on page 1 that just need a boost...', + 'Identifying keyword gaps where better content could move you to page 1...', + 'Reviewing your pages for optimization opportunities...', + 'Computing your SEO health score and benchmark metrics...', + 'Generating AI-powered blog post recommendations tailored to your GSC data...', + 'Formatting insights into actionable topic suggestions you can use today...', +]; + export const useGSCBrainstorm = (): UseGSCBrainstormReturn => { const { getToken } = useAuth(); const { @@ -41,11 +58,45 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => { const [isBrainstorming, setIsBrainstorming] = useState(false); const [brainstormError, setBrainstormError] = useState(null); const [brainstormResult, setBrainstormResult] = useState(null); + const [progressMessage, setProgressMessage] = useState(''); + const progressIndexRef = useRef(0); + const progressTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + } + }; + }, []); + + const startProgressMessages = () => { + progressIndexRef.current = 0; + setProgressMessage(PROGRESS_MESSAGES[0]); + progressTimerRef.current = setInterval(() => { + progressIndexRef.current += 1; + if (progressIndexRef.current < PROGRESS_MESSAGES.length) { + setProgressMessage(PROGRESS_MESSAGES[progressIndexRef.current]); + } else if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + }, 3000); + }; + + const stopProgressMessages = () => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + setProgressMessage(''); + }; const brainstorm = useCallback( async (keywords: string, siteUrl?: string): Promise => { setIsBrainstorming(true); setBrainstormError(null); + startProgressMessages(); try { gscBrainstormAPI.setAuthTokenGetter(async () => { @@ -66,6 +117,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => { return null; } finally { setIsBrainstorming(false); + stopProgressMessages(); } }, [getToken], @@ -75,6 +127,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => { setBrainstormResult(null); setBrainstormError(null); setIsBrainstorming(false); + stopProgressMessages(); }, []); return { @@ -87,16 +140,19 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => { brainstormResult, contentOpportunities: brainstormResult?.content_opportunities ?? [], keywordGaps: brainstormResult?.keyword_gaps ?? [], + quickWins: brainstormResult?.quick_wins ?? [], + pageOpportunities: brainstormResult?.page_opportunities ?? [], aiRecommendations: brainstormResult?.ai_recommendations - && Object.keys(brainstormResult.ai_recommendations).length > 0 + && Array.isArray(brainstormResult.ai_recommendations?.immediate_opportunities) ? (brainstormResult.ai_recommendations as AIRecommendations) : null, summary: brainstormResult?.summary - && Object.keys(brainstormResult.summary).length > 0 + && brainstormResult.summary.site_url ? (brainstormResult.summary as BrainstormSummary) : null, connectGSC, brainstorm, reset, + progressMessage, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useGSCBrainstormConnection.ts b/frontend/src/hooks/useGSCBrainstormConnection.ts index ebc169f0..2a8ef58f 100644 --- a/frontend/src/hooks/useGSCBrainstormConnection.ts +++ b/frontend/src/hooks/useGSCBrainstormConnection.ts @@ -92,54 +92,84 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn = } await new Promise((resolve) => { - let messageHandled = false; + let resolved = false; + const finish = (connected: boolean) => { + if (resolved) return; + resolved = true; + clearInterval(pollInterval); + clearTimeout(safetyTimeout); + window.removeEventListener('message', messageHandler); + clearInterval(connectionCheckInterval); + try { popup.close(); } catch { /* COOP may block close across origins */ } + if (connected) { + checkConnection().then(() => { + cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error); + resolve(); + }); + } else { + setConnectError('Google Search Console connection was cancelled or failed.'); + resolve(); + } + }; + + // 1. Listen for postMessage from callback page (primary mechanism) const messageHandler = (event: MessageEvent) => { - if (messageHandled) return; + if (resolved) return; if (!event?.data || typeof event.data !== 'object') return; const { type } = event.data as { type?: string }; - if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') { - messageHandled = true; - try { popup.close(); } catch {} - window.removeEventListener('message', messageHandler); - - if (type === 'GSC_AUTH_SUCCESS') { - checkConnection().then(() => { - cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error); - resolve(); - }); - } else { - setConnectError('Google Search Console connection was cancelled or failed.'); - resolve(); - } + if (type === 'GSC_AUTH_SUCCESS') { + finish(true); + } else if (type === 'GSC_AUTH_ERROR') { + finish(false); } }; window.addEventListener('message', messageHandler); - const safetyTimeout = setTimeout(() => { - if (!messageHandled) { - try { if (!popup.closed) popup.close(); } catch {} - window.removeEventListener('message', messageHandler); - checkConnection().then(() => resolve()); - } - }, 3 * 60 * 1000); - + // 2. Poll popup.closed (works when popup stays same-origin) const pollInterval = setInterval(() => { + if (resolved) return; try { if (popup.closed) { - clearInterval(pollInterval); - clearTimeout(safetyTimeout); - window.removeEventListener('message', messageHandler); - if (!messageHandled) { - checkConnection().then(() => resolve()); - } + // Popup closed — check if connection succeeded + checkConnection().then((connected) => { + if (connected) { + finish(true); + } else if (!resolved) { + // Popup closed without connecting — give a brief window for backend to finish + setTimeout(() => { + if (!resolved) { + checkConnection().then((c) => finish(c)); + } + }, 1000); + } + }); } } catch { - clearInterval(pollInterval); + // COOP blocks popup.closed access; rely on other mechanisms } - }, 1000); + }, 500); + + // 3. Poll backend connection status (works even when postMessage is blocked) + // Checks every 2s after a 1s initial delay to let the OAuth flow complete + let checkCount = 0; + const connectionCheckInterval = setInterval(() => { + if (resolved) return; + checkCount++; + if (checkCount < 2) return; // Skip first 2 checks (1s) to let OAuth start + checkConnection().then((connected) => { + if (connected) finish(true); + }); + }, 1500); + + // 4. Safety timeout + const safetyTimeout = setTimeout(() => { + if (!resolved) { + checkConnection().then((connected) => finish(connected)); + } + }, 2 * 60 * 1000); // 2 min safety timeout (reduced from 3) }); } catch (error) { console.error('GSC OAuth error:', error); diff --git a/frontend/src/services/blogWriterApi.ts b/frontend/src/services/blogWriterApi.ts index b2e23956..388ada11 100644 --- a/frontend/src/services/blogWriterApi.ts +++ b/frontend/src/services/blogWriterApi.ts @@ -645,11 +645,12 @@ export interface AssistiveSuggestion { export interface AssistiveSuggestionResponse { success: boolean; suggestions: AssistiveSuggestion[]; + message?: string; } export const assistiveWritingApi = { - async getSuggestion(text: string): Promise { - const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text }); + async getSuggestion(text: string, cursorPosition?: number): Promise { + const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text, cursor_position: cursorPosition }); return data; } }; diff --git a/frontend/src/services/hallucinationDetectorService.ts b/frontend/src/services/hallucinationDetectorService.ts index 2f97e347..f8c133ff 100644 --- a/frontend/src/services/hallucinationDetectorService.ts +++ b/frontend/src/services/hallucinationDetectorService.ts @@ -2,6 +2,8 @@ * Service for calling the hallucination detector API endpoints. */ +import { longRunningApiClient } from '../api/client'; + export interface SourceDocument { title: string; url: string; @@ -75,7 +77,6 @@ export interface HealthCheckResponse { class HallucinationDetectorService { private baseUrl: string; - private authTokenGetter: (() => Promise) | null = null; constructor() { const getApiBaseUrl = () => { @@ -88,19 +89,9 @@ class HallucinationDetectorService { this.baseUrl = getApiBaseUrl(); } - setAuthTokenGetter(getter: (() => Promise) | null) { - this.authTokenGetter = getter; - } - - private async getAuthHeaders(): Promise> { - const headers: Record = { 'Content-Type': 'application/json' }; - if (this.authTokenGetter) { - const token = await this.authTokenGetter(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - return headers; + // Kept for backward compatibility — auth is now handled by longRunningApiClient interceptors + setAuthTokenGetter(_getter: (() => Promise) | null) { + // no-op } /** @@ -109,28 +100,17 @@ class HallucinationDetectorService { async detectHallucinations(request: HallucinationDetectionRequest): Promise { console.log('šŸ” [HallucinationDetectorService] detectHallucinations called with request:', request); try { - const url = `${this.baseUrl}/api/hallucination-detector/detect`; + const url = `/api/hallucination-detector/detect`; console.log('šŸ” [HallucinationDetectorService] Making request to:', url); - const response = await fetch(url, { - method: 'POST', - headers: await this.getAuthHeaders(), - body: JSON.stringify(request), - }); + const response = await longRunningApiClient.post(url, request); - console.log('šŸ” [HallucinationDetectorService] Response status:', response.status, 'OK:', response.ok); - - if (!response.ok) { - const errorText = await response.text(); - console.error('šŸ” [HallucinationDetectorService] HTTP error response:', errorText); - throw new Error(`HTTP error! status: ${response.status} - ${errorText}`); - } - - const data = await response.json(); - console.log('šŸ” [HallucinationDetectorService] Response data:', data); - return data; - } catch (error) { + console.log('šŸ” [HallucinationDetectorService] Response status:', response.status, 'OK:', response.status === 200); + console.log('šŸ” [HallucinationDetectorService] Response data:', response.data); + return response.data; + } catch (error: any) { console.error('šŸ” [HallucinationDetectorService] Error detecting hallucinations:', error); + const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Unknown error occurred'; return { success: false, claims: [], @@ -140,7 +120,7 @@ class HallucinationDetectorService { refuted_claims: 0, insufficient_claims: 0, timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error occurred' + error: errorMessage }; } } @@ -150,26 +130,16 @@ class HallucinationDetectorService { */ async extractClaims(request: ClaimExtractionRequest): Promise { try { - const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, { - method: 'POST', - headers: await this.getAuthHeaders(), - body: JSON.stringify(request), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data; - } catch (error) { + const response = await longRunningApiClient.post('/api/hallucination-detector/extract-claims', request); + return response.data; + } catch (error: any) { console.error('Error extracting claims:', error); return { success: false, claims: [], total_claims: 0, timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error occurred' + error: error?.response?.data?.error || error?.message || 'Unknown error occurred' }; } } @@ -179,19 +149,9 @@ class HallucinationDetectorService { */ async verifyClaim(request: ClaimVerificationRequest): Promise { try { - const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, { - method: 'POST', - headers: await this.getAuthHeaders(), - body: JSON.stringify(request), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data; - } catch (error) { + const response = await longRunningApiClient.post('/api/hallucination-detector/verify-claim', request); + return response.data; + } catch (error: any) { console.error('Error verifying claim:', error); return { success: false, @@ -204,7 +164,7 @@ class HallucinationDetectorService { reasoning: 'Error during verification' }, timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error occurred' + error: error?.response?.data?.error || error?.message || 'Unknown error occurred' }; } } @@ -214,15 +174,9 @@ class HallucinationDetectorService { */ async healthCheck(): Promise { try { - const response = await fetch(`${this.baseUrl}/api/hallucination-detector/health`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data; - } catch (error) { + const response = await longRunningApiClient.get('/api/hallucination-detector/health'); + return response.data; + } catch (error: any) { console.error('Error checking health:', error); return { status: 'unhealthy', @@ -239,14 +193,8 @@ class HallucinationDetectorService { */ async getDemoInfo(): Promise { try { - const response = await fetch(`${this.baseUrl}/api/hallucination-detector/demo`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return data; + const response = await longRunningApiClient.get('/api/hallucination-detector/demo'); + return response.data; } catch (error) { console.error('Error getting demo info:', error); return null;