AI platform insights monitoring and website analysis monitoring services added
This commit is contained in:
@@ -81,6 +81,30 @@ class OnboardingCompletionService:
|
|||||||
# Non-critical: log but don't fail onboarding completion
|
# Non-critical: log but don't fail onboarding completion
|
||||||
logger.warning(f"Failed to create OAuth token monitoring tasks for user {user_id}: {e}")
|
logger.warning(f"Failed to create OAuth token monitoring tasks for user {user_id}: {e}")
|
||||||
|
|
||||||
|
# Create website analysis tasks for user's website and competitors
|
||||||
|
try:
|
||||||
|
from services.database import SessionLocal
|
||||||
|
from services.website_analysis_monitoring_service import create_website_analysis_tasks
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = create_website_analysis_tasks(user_id=user_id, db=db)
|
||||||
|
if result.get('success'):
|
||||||
|
tasks_count = result.get('tasks_created', 0)
|
||||||
|
logger.info(
|
||||||
|
f"Created {tasks_count} website analysis tasks for user {user_id} "
|
||||||
|
f"on onboarding completion"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error = result.get('error', 'Unknown error')
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to create website analysis tasks for user {user_id}: {error}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
# Non-critical: log but don't fail onboarding completion
|
||||||
|
logger.warning(f"Failed to create website analysis tasks for user {user_id}: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Onboarding completed successfully",
|
"message": "Onboarding completed successfully",
|
||||||
"completed_at": datetime.now().isoformat(),
|
"completed_at": datetime.now().isoformat(),
|
||||||
|
|||||||
@@ -432,13 +432,13 @@ class Step3ResearchService:
|
|||||||
logger.error(f"Error storing research data: {str(e)}")
|
logger.error(f"Error storing research data: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_research_data(self, session_id: str) -> Dict[str, Any]:
|
async def get_research_data(self, session_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Retrieve research data for a session.
|
Retrieve research data for a session.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: Onboarding session ID
|
session_id: Onboarding session ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing research data
|
Dictionary containing research data
|
||||||
"""
|
"""
|
||||||
@@ -447,25 +447,76 @@ class Step3ResearchService:
|
|||||||
session = db.query(OnboardingSession).filter(
|
session = db.query(OnboardingSession).filter(
|
||||||
OnboardingSession.id == session_id
|
OnboardingSession.id == session_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Session not found"
|
"error": "Session not found"
|
||||||
}
|
}
|
||||||
|
|
||||||
research_data = session.step_data.get("step3_research_data") if session.step_data else None
|
# Check if step_data attribute exists (it may not be in the model)
|
||||||
|
# If it doesn't exist, try to get data from CompetitorAnalysis table
|
||||||
|
research_data = None
|
||||||
|
if hasattr(session, 'step_data') and session.step_data:
|
||||||
|
research_data = session.step_data.get("step3_research_data") if isinstance(session.step_data, dict) else None
|
||||||
|
|
||||||
|
# If not found in step_data, try CompetitorAnalysis table
|
||||||
|
if not research_data:
|
||||||
|
try:
|
||||||
|
from models.onboarding import CompetitorAnalysis
|
||||||
|
competitor_records = db.query(CompetitorAnalysis).filter(
|
||||||
|
CompetitorAnalysis.session_id == session.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if competitor_records:
|
||||||
|
competitors = []
|
||||||
|
for record in competitor_records:
|
||||||
|
analysis_data = record.analysis_data or {}
|
||||||
|
competitor_info = {
|
||||||
|
"url": record.competitor_url,
|
||||||
|
"domain": record.competitor_domain or record.competitor_url,
|
||||||
|
"title": analysis_data.get("title", record.competitor_domain or ""),
|
||||||
|
"summary": analysis_data.get("summary", ""),
|
||||||
|
"relevance_score": analysis_data.get("relevance_score", 0.5),
|
||||||
|
"highlights": analysis_data.get("highlights", []),
|
||||||
|
"favicon": analysis_data.get("favicon"),
|
||||||
|
"image": analysis_data.get("image"),
|
||||||
|
"published_date": analysis_data.get("published_date"),
|
||||||
|
"author": analysis_data.get("author"),
|
||||||
|
"competitive_insights": analysis_data.get("competitive_analysis", {}),
|
||||||
|
"content_insights": analysis_data.get("content_insights", {})
|
||||||
|
}
|
||||||
|
competitors.append(competitor_info)
|
||||||
|
|
||||||
|
if competitors:
|
||||||
|
# Map competitor fields to match frontend expectations
|
||||||
|
mapped_competitors = []
|
||||||
|
for comp in competitors:
|
||||||
|
mapped_comp = {
|
||||||
|
**comp, # Keep all original fields
|
||||||
|
"name": comp.get("title") or comp.get("name") or comp.get("domain", ""),
|
||||||
|
"description": comp.get("summary") or comp.get("description", ""),
|
||||||
|
"similarity_score": comp.get("relevance_score") or comp.get("similarity_score", 0.5)
|
||||||
|
}
|
||||||
|
mapped_competitors.append(mapped_comp)
|
||||||
|
|
||||||
|
research_data = {
|
||||||
|
"competitors": mapped_competitors,
|
||||||
|
"completed_at": competitor_records[0].created_at.isoformat() if competitor_records[0].created_at else None
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not retrieve competitors from CompetitorAnalysis table: {e}")
|
||||||
|
|
||||||
if not research_data:
|
if not research_data:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "No research data found for this session"
|
"error": "No research data found for this session"
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"research_data": research_data,
|
"step3_research_data": research_data,
|
||||||
"session_id": session_id
|
"research_data": research_data # Keep for backward compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ Provides provider availability and persona-aware defaults for research.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional, List
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from services.user_api_key_context import get_exa_key, get_gemini_key
|
from services.user_api_key_context import get_exa_key, get_gemini_key, get_tavily_key
|
||||||
from services.onboarding.database_service import OnboardingDatabaseService
|
from services.onboarding.database_service import OnboardingDatabaseService
|
||||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||||
from services.database import get_db
|
from services.database import get_db
|
||||||
@@ -26,8 +26,10 @@ class ProviderAvailability(BaseModel):
|
|||||||
"""Provider availability status."""
|
"""Provider availability status."""
|
||||||
google_available: bool
|
google_available: bool
|
||||||
exa_available: bool
|
exa_available: bool
|
||||||
|
tavily_available: bool
|
||||||
gemini_key_status: str # 'configured' | 'missing'
|
gemini_key_status: str # 'configured' | 'missing'
|
||||||
exa_key_status: str # 'configured' | 'missing'
|
exa_key_status: str # 'configured' | 'missing'
|
||||||
|
tavily_key_status: str # 'configured' | 'missing'
|
||||||
|
|
||||||
|
|
||||||
class PersonaDefaults(BaseModel):
|
class PersonaDefaults(BaseModel):
|
||||||
@@ -47,6 +49,17 @@ class ResearchConfigResponse(BaseModel):
|
|||||||
persona_scheduled: bool = False
|
persona_scheduled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitorAnalysisResponse(BaseModel):
|
||||||
|
"""Response model for competitor analysis data."""
|
||||||
|
success: bool
|
||||||
|
competitors: Optional[List[Dict[str, Any]]] = None
|
||||||
|
social_media_accounts: Optional[Dict[str, str]] = None
|
||||||
|
social_media_citations: Optional[List[Dict[str, Any]]] = None
|
||||||
|
research_summary: Optional[Dict[str, Any]] = None
|
||||||
|
analysis_timestamp: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/provider-availability", response_model=ProviderAvailability)
|
@router.get("/provider-availability", response_model=ProviderAvailability)
|
||||||
async def get_provider_availability(
|
async def get_provider_availability(
|
||||||
current_user: Dict = Depends(get_current_user)
|
current_user: Dict = Depends(get_current_user)
|
||||||
@@ -57,6 +70,7 @@ async def get_provider_availability(
|
|||||||
Returns:
|
Returns:
|
||||||
- google_available: True if Gemini key is configured
|
- google_available: True if Gemini key is configured
|
||||||
- exa_available: True if Exa key is configured
|
- exa_available: True if Exa key is configured
|
||||||
|
- tavily_available: True if Tavily key is configured
|
||||||
- Key status for each provider
|
- Key status for each provider
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -65,15 +79,19 @@ async def get_provider_availability(
|
|||||||
# Check API key availability
|
# Check API key availability
|
||||||
gemini_key = get_gemini_key(user_id)
|
gemini_key = get_gemini_key(user_id)
|
||||||
exa_key = get_exa_key(user_id)
|
exa_key = get_exa_key(user_id)
|
||||||
|
tavily_key = get_tavily_key(user_id)
|
||||||
|
|
||||||
google_available = bool(gemini_key and gemini_key.strip())
|
google_available = bool(gemini_key and gemini_key.strip())
|
||||||
exa_available = bool(exa_key and exa_key.strip())
|
exa_available = bool(exa_key and exa_key.strip())
|
||||||
|
tavily_available = bool(tavily_key and tavily_key.strip())
|
||||||
|
|
||||||
return ProviderAvailability(
|
return ProviderAvailability(
|
||||||
google_available=google_available,
|
google_available=google_available,
|
||||||
exa_available=exa_available,
|
exa_available=exa_available,
|
||||||
|
tavily_available=tavily_available,
|
||||||
gemini_key_status='configured' if google_available else 'missing',
|
gemini_key_status='configured' if google_available else 'missing',
|
||||||
exa_key_status='configured' if exa_available else 'missing'
|
exa_key_status='configured' if exa_available else 'missing',
|
||||||
|
tavily_key_status='configured' if tavily_available else 'missing'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[ResearchConfig] Error checking provider availability for user {user_id if 'user_id' in locals() else 'unknown'}: {e}", exc_info=True)
|
logger.error(f"[ResearchConfig] Error checking provider availability for user {user_id if 'user_id' in locals() else 'unknown'}: {e}", exc_info=True)
|
||||||
@@ -211,15 +229,19 @@ async def get_research_config(
|
|||||||
logger.debug(f"[ResearchConfig] Getting provider availability for user {user_id}")
|
logger.debug(f"[ResearchConfig] Getting provider availability for user {user_id}")
|
||||||
gemini_key = get_gemini_key(user_id)
|
gemini_key = get_gemini_key(user_id)
|
||||||
exa_key = get_exa_key(user_id)
|
exa_key = get_exa_key(user_id)
|
||||||
|
tavily_key = get_tavily_key(user_id)
|
||||||
|
|
||||||
google_available = bool(gemini_key and gemini_key.strip())
|
google_available = bool(gemini_key and gemini_key.strip())
|
||||||
exa_available = bool(exa_key and exa_key.strip())
|
exa_available = bool(exa_key and exa_key.strip())
|
||||||
|
tavily_available = bool(tavily_key and tavily_key.strip())
|
||||||
|
|
||||||
provider_availability = ProviderAvailability(
|
provider_availability = ProviderAvailability(
|
||||||
google_available=google_available,
|
google_available=google_available,
|
||||||
exa_available=exa_available,
|
exa_available=exa_available,
|
||||||
|
tavily_available=tavily_available,
|
||||||
gemini_key_status='configured' if google_available else 'missing',
|
gemini_key_status='configured' if google_available else 'missing',
|
||||||
exa_key_status='configured' if exa_available else 'missing'
|
exa_key_status='configured' if exa_available else 'missing',
|
||||||
|
tavily_key_status='configured' if tavily_available else 'missing'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get persona defaults
|
# Get persona defaults
|
||||||
@@ -355,11 +377,190 @@ async def get_research_config(
|
|||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[ResearchConfig] Full traceback:\n{traceback.format_exc()}")
|
logger.error(f"[ResearchConfig] Full traceback:\n{traceback.format_exc()}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to get research config: {str(e)}"
|
detail=f"Failed to get research config: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/competitor-analysis", response_model=CompetitorAnalysisResponse)
|
||||||
|
async def get_competitor_analysis(
|
||||||
|
current_user: Dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get competitor analysis data from onboarding for the current user.
|
||||||
|
|
||||||
|
Returns competitor data including competitors list, social media accounts,
|
||||||
|
social media citations, and research summary that was collected during onboarding step 3.
|
||||||
|
"""
|
||||||
|
user_id = None
|
||||||
|
try:
|
||||||
|
user_id = str(current_user.get('id'))
|
||||||
|
print(f"\n[COMPETITOR_ANALYSIS] ===== START: Getting competitor analysis for user_id={user_id} =====")
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Current user dict keys: {list(current_user.keys())}")
|
||||||
|
logger.info(f"[ResearchConfig] Getting competitor analysis for user {user_id}")
|
||||||
|
|
||||||
|
if not db:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ ERROR: Database session is None for user {user_id}")
|
||||||
|
logger.error(f"[ResearchConfig] Database session is None for user {user_id}")
|
||||||
|
raise HTTPException(status_code=500, detail="Database session not available")
|
||||||
|
|
||||||
|
db_service = OnboardingDatabaseService(db=db)
|
||||||
|
|
||||||
|
# Get onboarding session - using same pattern as onboarding completion check
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Looking up onboarding session for user_id={user_id} (Clerk ID)")
|
||||||
|
session = db_service.get_session_by_user(user_id, db)
|
||||||
|
if not session:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ WARNING: No onboarding session found for user_id={user_id}")
|
||||||
|
logger.warning(f"[ResearchConfig] No onboarding session found for user {user_id}")
|
||||||
|
return CompetitorAnalysisResponse(
|
||||||
|
success=False,
|
||||||
|
error="No onboarding session found. Please complete onboarding first."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ Found onboarding session: id={session.id}, user_id={session.user_id}, current_step={session.current_step}")
|
||||||
|
|
||||||
|
# Check if step 3 is completed - same pattern as elsewhere (check current_step >= 3 or research_preferences exists)
|
||||||
|
research_preferences = db_service.get_research_preferences(user_id, db)
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Step check: current_step={session.current_step}, research_preferences exists={research_preferences is not None}")
|
||||||
|
if not research_preferences and session.current_step < 3:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ Step 3 not completed for user_id={user_id} (current_step={session.current_step})")
|
||||||
|
logger.info(f"[ResearchConfig] Step 3 not completed for user {user_id} (current_step={session.current_step})")
|
||||||
|
return CompetitorAnalysisResponse(
|
||||||
|
success=False,
|
||||||
|
error="Onboarding step 3 (Competitor Analysis) is not completed. Please complete onboarding step 3 first."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ Step 3 is completed (current_step={session.current_step} or research_preferences exists)")
|
||||||
|
|
||||||
|
# Try Method 1: Get competitor data from CompetitorAnalysis table using OnboardingDatabaseService
|
||||||
|
# This follows the same pattern as get_website_analysis()
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] 🔍 Method 1: Querying CompetitorAnalysis table using OnboardingDatabaseService...")
|
||||||
|
try:
|
||||||
|
competitors = db_service.get_competitor_analysis(user_id, db)
|
||||||
|
|
||||||
|
if competitors:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ Found {len(competitors)} competitor records from CompetitorAnalysis table")
|
||||||
|
logger.info(f"[ResearchConfig] Found {len(competitors)} competitors from CompetitorAnalysis table for user {user_id}")
|
||||||
|
|
||||||
|
# Map competitor fields to match frontend expectations
|
||||||
|
mapped_competitors = []
|
||||||
|
for comp in competitors:
|
||||||
|
mapped_comp = {
|
||||||
|
**comp, # Keep all original fields
|
||||||
|
"name": comp.get("title") or comp.get("name") or comp.get("domain", ""),
|
||||||
|
"description": comp.get("summary") or comp.get("description", ""),
|
||||||
|
"similarity_score": comp.get("relevance_score") or comp.get("similarity_score", 0.5)
|
||||||
|
}
|
||||||
|
mapped_competitors.append(mapped_comp)
|
||||||
|
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ SUCCESS: Returning {len(mapped_competitors)} competitors for user_id={user_id}")
|
||||||
|
return CompetitorAnalysisResponse(
|
||||||
|
success=True,
|
||||||
|
competitors=mapped_competitors,
|
||||||
|
social_media_accounts={},
|
||||||
|
social_media_citations=[],
|
||||||
|
research_summary={
|
||||||
|
"total_competitors": len(mapped_competitors),
|
||||||
|
"market_insights": f"Found {len(mapped_competitors)} competitors analyzed during onboarding"
|
||||||
|
},
|
||||||
|
analysis_timestamp=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ⚠️ No competitor records found in CompetitorAnalysis table for user_id={user_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ EXCEPTION in Method 1: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Traceback:\n{traceback.format_exc()}")
|
||||||
|
logger.warning(f"[ResearchConfig] Could not retrieve competitor data from CompetitorAnalysis table: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Try Method 2: Get data from Step3ResearchService (which accesses step_data)
|
||||||
|
# This is where step3_research_service._store_research_data() saves the data
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] 🔄 Method 2: Trying Step3ResearchService.get_research_data()...")
|
||||||
|
try:
|
||||||
|
from api.onboarding_utils.step3_research_service import Step3ResearchService
|
||||||
|
|
||||||
|
# Step3ResearchService.get_research_data() expects session_id (integer), but we have user_id (string)
|
||||||
|
# The service uses session.id internally, so we need to pass the session.id
|
||||||
|
step3_service = Step3ResearchService()
|
||||||
|
research_data_result = await step3_service.get_research_data(str(session.id))
|
||||||
|
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Step3ResearchService.get_research_data() result: success={research_data_result.get('success')}")
|
||||||
|
|
||||||
|
if research_data_result.get('success'):
|
||||||
|
# Handle both 'research_data' and 'step3_research_data' keys
|
||||||
|
research_data = research_data_result.get('step3_research_data') or research_data_result.get('research_data', {})
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Research data keys: {list(research_data.keys()) if isinstance(research_data, dict) else 'Not a dict'}")
|
||||||
|
|
||||||
|
if isinstance(research_data, dict) and research_data.get('competitors'):
|
||||||
|
competitors_list = research_data.get('competitors', [])
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ Found {len(competitors_list)} competitors in step_data via Step3ResearchService")
|
||||||
|
|
||||||
|
if competitors_list:
|
||||||
|
analysis_metadata = research_data.get('analysis_metadata', {})
|
||||||
|
social_media_data = analysis_metadata.get('social_media_data', {})
|
||||||
|
|
||||||
|
# Map competitor fields to match frontend expectations
|
||||||
|
mapped_competitors = []
|
||||||
|
for comp in competitors_list:
|
||||||
|
mapped_comp = {
|
||||||
|
**comp, # Keep all original fields
|
||||||
|
"name": comp.get("title") or comp.get("name") or comp.get("domain", ""),
|
||||||
|
"description": comp.get("summary") or comp.get("description", ""),
|
||||||
|
"similarity_score": comp.get("relevance_score") or comp.get("similarity_score", 0.5)
|
||||||
|
}
|
||||||
|
mapped_competitors.append(mapped_comp)
|
||||||
|
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ✅ SUCCESS: Returning {len(mapped_competitors)} competitors from step_data for user_id={user_id}")
|
||||||
|
logger.info(f"[ResearchConfig] Found {len(mapped_competitors)} competitors from step_data via Step3ResearchService for user {user_id}")
|
||||||
|
return CompetitorAnalysisResponse(
|
||||||
|
success=True,
|
||||||
|
competitors=mapped_competitors,
|
||||||
|
social_media_accounts=social_media_data.get('social_media_accounts', {}),
|
||||||
|
social_media_citations=social_media_data.get('citations', []),
|
||||||
|
research_summary=research_data.get('research_summary'),
|
||||||
|
analysis_timestamp=research_data.get('completed_at')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ⚠️ Step3ResearchService returned competitors list but it's empty")
|
||||||
|
else:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ⚠️ Step3ResearchService returned success=True but no competitors in data")
|
||||||
|
else:
|
||||||
|
error_msg = research_data_result.get('error', 'Unknown error')
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ⚠️ Step3ResearchService returned success=False, error: {error_msg}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ EXCEPTION in Method 2: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Traceback:\n{traceback.format_exc()}")
|
||||||
|
logger.warning(f"[ResearchConfig] Could not retrieve competitor data from Step3ResearchService: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Fallback: Return empty response with helpful message
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ FALLBACK: No competitor analysis data found for user_id={user_id}")
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Step 3 is completed (current_step={session.current_step}) but no data found in either source")
|
||||||
|
logger.info(f"[ResearchConfig] No competitor analysis data found for user {user_id} (step 3 completed but no data found)")
|
||||||
|
return CompetitorAnalysisResponse(
|
||||||
|
success=False,
|
||||||
|
error="Competitor analysis data was not found in the database. Please re-run competitor discovery in Step 3 of onboarding to generate and save competitor data."
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ HTTPException raised (will be re-raised)")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ❌ CRITICAL ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] Traceback:\n{traceback.format_exc()}")
|
||||||
|
logger.error(f"[ResearchConfig] Error getting competitor analysis for user {user_id if user_id else 'unknown'}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get competitor analysis: {str(e)}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
print(f"[COMPETITOR_ANALYSIS] ===== END: Getting competitor analysis for user_id={user_id} =====\n")
|
||||||
|
|
||||||
|
|
||||||
# Helper functions from RESEARCH_AI_HYPERPERSONALIZATION.md
|
# Helper functions from RESEARCH_AI_HYPERPERSONALIZATION.md
|
||||||
|
|
||||||
def _get_domain_suggestions(industry: str) -> list[str]:
|
def _get_domain_suggestions(industry: str) -> list[str]:
|
||||||
|
|||||||
@@ -18,11 +18,68 @@ from middleware.auth_middleware import get_current_user
|
|||||||
from models.monitoring_models import TaskExecutionLog, MonitoringTask
|
from models.monitoring_models import TaskExecutionLog, MonitoringTask
|
||||||
from models.scheduler_models import SchedulerEventLog
|
from models.scheduler_models import SchedulerEventLog
|
||||||
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
|
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
|
||||||
from sqlalchemy import func
|
from models.platform_insights_monitoring_models import PlatformInsightsTask, PlatformInsightsExecutionLog
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask, WebsiteAnalysisExecutionLog
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler-dashboard"])
|
router = APIRouter(prefix="/api/scheduler", tags=["scheduler-dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_cumulative_stats_from_events(db: Session) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Rebuild cumulative stats by aggregating all check_cycle events from event logs.
|
||||||
|
This is used as a fallback when the cumulative stats table doesn't exist or is invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with cumulative stats
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Aggregate check cycle events for cumulative totals
|
||||||
|
result = db.query(
|
||||||
|
func.count(SchedulerEventLog.id),
|
||||||
|
func.sum(SchedulerEventLog.tasks_found),
|
||||||
|
func.sum(SchedulerEventLog.tasks_executed),
|
||||||
|
func.sum(SchedulerEventLog.tasks_failed)
|
||||||
|
).filter(
|
||||||
|
SchedulerEventLog.event_type == 'check_cycle'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
# SQLAlchemy returns tuple for multi-column queries
|
||||||
|
# SUM returns NULL when no rows, handle that
|
||||||
|
total_cycles = result[0] if result[0] is not None else 0
|
||||||
|
total_found = result[1] if result[1] is not None else 0
|
||||||
|
total_executed = result[2] if result[2] is not None else 0
|
||||||
|
total_failed = result[3] if result[3] is not None else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_check_cycles': int(total_cycles),
|
||||||
|
'cumulative_tasks_found': int(total_found),
|
||||||
|
'cumulative_tasks_executed': int(total_executed),
|
||||||
|
'cumulative_tasks_failed': int(total_failed),
|
||||||
|
'cumulative_tasks_skipped': 0 # Not tracked in event logs currently
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'total_check_cycles': 0,
|
||||||
|
'cumulative_tasks_found': 0,
|
||||||
|
'cumulative_tasks_executed': 0,
|
||||||
|
'cumulative_tasks_failed': 0,
|
||||||
|
'cumulative_tasks_skipped': 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Dashboard] Error rebuilding cumulative stats from events: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'total_check_cycles': 0,
|
||||||
|
'cumulative_tasks_found': 0,
|
||||||
|
'cumulative_tasks_executed': 0,
|
||||||
|
'cumulative_tasks_failed': 0,
|
||||||
|
'cumulative_tasks_skipped': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard")
|
@router.get("/dashboard")
|
||||||
async def get_scheduler_dashboard(
|
async def get_scheduler_dashboard(
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
@@ -139,98 +196,172 @@ async def get_scheduler_dashboard(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading OAuth token monitoring tasks: {e}", exc_info=True)
|
logger.error(f"Error loading OAuth token monitoring tasks: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Load website analysis tasks
|
||||||
|
try:
|
||||||
|
website_analysis_tasks = db.query(WebsiteAnalysisTask).filter(
|
||||||
|
WebsiteAnalysisTask.status == 'active'
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Filter by user if user_id_str is provided
|
||||||
|
if user_id_str:
|
||||||
|
website_analysis_tasks = [t for t in website_analysis_tasks if t.user_id == user_id_str]
|
||||||
|
|
||||||
|
for task in website_analysis_tasks:
|
||||||
|
try:
|
||||||
|
user_job_store = get_user_job_store_name(task.user_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
user_job_store = 'default'
|
||||||
|
logger.debug(f"Could not get job store for user {task.user_id}: {e}")
|
||||||
|
|
||||||
|
# Format as recurring job
|
||||||
|
job_info = {
|
||||||
|
'id': f"website_analysis_{task.task_type}_{task.user_id}_{task.id}",
|
||||||
|
'trigger_type': 'CronTrigger', # Recurring based on frequency_days
|
||||||
|
'next_run_time': task.next_check.isoformat() if task.next_check else None,
|
||||||
|
'user_id': task.user_id,
|
||||||
|
'job_store': 'default',
|
||||||
|
'user_job_store': user_job_store,
|
||||||
|
'function_name': 'website_analysis_executor.execute_task',
|
||||||
|
'task_type': task.task_type, # 'user_website' or 'competitor'
|
||||||
|
'website_url': task.website_url,
|
||||||
|
'competitor_id': task.competitor_id,
|
||||||
|
'task_id': task.id,
|
||||||
|
'is_database_task': True,
|
||||||
|
'frequency': f'Every {task.frequency_days} days',
|
||||||
|
'task_category': 'website_analysis'
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted_jobs.append(job_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading website analysis tasks: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Load platform insights tasks (GSC and Bing)
|
||||||
|
try:
|
||||||
|
insights_tasks = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.status == 'active'
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Filter by user if user_id_str is provided
|
||||||
|
if user_id_str:
|
||||||
|
insights_tasks = [t for t in insights_tasks if t.user_id == user_id_str]
|
||||||
|
|
||||||
|
for task in insights_tasks:
|
||||||
|
try:
|
||||||
|
user_job_store = get_user_job_store_name(task.user_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
user_job_store = 'default'
|
||||||
|
logger.debug(f"Could not get job store for user {task.user_id}: {e}")
|
||||||
|
|
||||||
|
# Format as recurring weekly job
|
||||||
|
job_info = {
|
||||||
|
'id': f"platform_insights_{task.platform}_{task.user_id}",
|
||||||
|
'trigger_type': 'CronTrigger', # Weekly recurring
|
||||||
|
'next_run_time': task.next_check.isoformat() if task.next_check else None,
|
||||||
|
'user_id': task.user_id,
|
||||||
|
'job_store': 'default',
|
||||||
|
'user_job_store': user_job_store,
|
||||||
|
'function_name': f'{task.platform}_insights_executor.execute_task',
|
||||||
|
'platform': task.platform,
|
||||||
|
'task_id': task.id,
|
||||||
|
'is_database_task': True,
|
||||||
|
'frequency': 'Weekly',
|
||||||
|
'task_category': 'platform_insights'
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted_jobs.append(job_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading platform insights tasks: {e}", exc_info=True)
|
||||||
|
|
||||||
# Get active strategies count
|
# Get active strategies count
|
||||||
active_strategies = stats.get('active_strategies_count', 0)
|
active_strategies = stats.get('active_strategies_count', 0)
|
||||||
|
|
||||||
# Get last_update from stats (added by scheduler for frontend polling)
|
# Get last_update from stats (added by scheduler for frontend polling)
|
||||||
last_update = stats.get('last_update')
|
last_update = stats.get('last_update')
|
||||||
|
|
||||||
# Calculate cumulative/historical values from scheduler_event_logs
|
# Calculate cumulative/historical values from persistent cumulative stats table
|
||||||
|
# Fallback to event logs aggregation if cumulative stats table doesn't exist or is invalid
|
||||||
cumulative_stats = {}
|
cumulative_stats = {}
|
||||||
try:
|
try:
|
||||||
# First, check total events in database for debugging
|
from models.scheduler_cumulative_stats_model import SchedulerCumulativeStats
|
||||||
total_events = db.query(func.count(SchedulerEventLog.id)).scalar() or 0
|
|
||||||
|
|
||||||
# Check for check_cycle events specifically
|
# Try to get cumulative stats from dedicated table (persistent across restarts)
|
||||||
check_cycle_count = db.query(func.count(SchedulerEventLog.id)).filter(
|
cumulative_stats_row = db.query(SchedulerCumulativeStats).filter(
|
||||||
SchedulerEventLog.event_type == 'check_cycle'
|
SchedulerCumulativeStats.id == 1
|
||||||
).scalar() or 0
|
).first()
|
||||||
|
|
||||||
# Also check for other event types that might have task counts
|
if cumulative_stats_row:
|
||||||
job_failed_count = db.query(func.count(SchedulerEventLog.id)).filter(
|
# Use persistent cumulative stats
|
||||||
SchedulerEventLog.event_type == 'job_failed'
|
cumulative_stats = {
|
||||||
).scalar() or 0
|
'total_check_cycles': int(cumulative_stats_row.total_check_cycles or 0),
|
||||||
job_completed_count = db.query(func.count(SchedulerEventLog.id)).filter(
|
'cumulative_tasks_found': int(cumulative_stats_row.cumulative_tasks_found or 0),
|
||||||
SchedulerEventLog.event_type == 'job_completed'
|
'cumulative_tasks_executed': int(cumulative_stats_row.cumulative_tasks_executed or 0),
|
||||||
).scalar() or 0
|
'cumulative_tasks_failed': int(cumulative_stats_row.cumulative_tasks_failed or 0),
|
||||||
|
'cumulative_tasks_skipped': int(cumulative_stats_row.cumulative_tasks_skipped or 0),
|
||||||
logger.warning(
|
'cumulative_job_completed': int(cumulative_stats_row.cumulative_job_completed or 0),
|
||||||
f"[Dashboard] Database stats: {total_events} total events, "
|
'cumulative_job_failed': int(cumulative_stats_row.cumulative_job_failed or 0)
|
||||||
f"{check_cycle_count} check_cycles, {job_failed_count} job_failed, "
|
}
|
||||||
f"{job_completed_count} job_completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
if check_cycle_count > 0:
|
|
||||||
logger.warning(f"[Dashboard] Found {check_cycle_count} check cycle events in database")
|
|
||||||
# Aggregate check cycle events for cumulative totals
|
|
||||||
result = db.query(
|
|
||||||
func.count(SchedulerEventLog.id),
|
|
||||||
func.sum(SchedulerEventLog.tasks_found),
|
|
||||||
func.sum(SchedulerEventLog.tasks_executed),
|
|
||||||
func.sum(SchedulerEventLog.tasks_failed)
|
|
||||||
).filter(
|
|
||||||
SchedulerEventLog.event_type == 'check_cycle'
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if result:
|
logger.debug(
|
||||||
# SQLAlchemy returns tuple for multi-column queries
|
f"[Dashboard] Using persistent cumulative stats: "
|
||||||
# SUM returns NULL when no rows, handle that
|
f"cycles={cumulative_stats['total_check_cycles']}, "
|
||||||
total_cycles = result[0] if result[0] is not None else 0
|
f"found={cumulative_stats['cumulative_tasks_found']}, "
|
||||||
total_found = result[1] if result[1] is not None else 0
|
f"executed={cumulative_stats['cumulative_tasks_executed']}, "
|
||||||
total_executed = result[2] if result[2] is not None else 0
|
f"failed={cumulative_stats['cumulative_tasks_failed']}"
|
||||||
total_failed = result[3] if result[3] is not None else 0
|
)
|
||||||
|
|
||||||
cumulative_stats = {
|
# Validate cumulative stats by comparing with event logs (for verification)
|
||||||
'total_check_cycles': int(total_cycles),
|
check_cycle_count = db.query(func.count(SchedulerEventLog.id)).filter(
|
||||||
'cumulative_tasks_found': int(total_found),
|
SchedulerEventLog.event_type == 'check_cycle'
|
||||||
'cumulative_tasks_executed': int(total_executed),
|
).scalar() or 0
|
||||||
'cumulative_tasks_failed': int(total_failed)
|
|
||||||
}
|
if cumulative_stats['total_check_cycles'] != check_cycle_count:
|
||||||
|
logger.warning(
|
||||||
logger.warning(f"[Dashboard] Cumulative stats from check_cycles: {cumulative_stats}")
|
f"[Dashboard] ⚠️ Cumulative stats validation mismatch: "
|
||||||
else:
|
f"cumulative_stats.total_check_cycles={cumulative_stats['total_check_cycles']} "
|
||||||
# No results (shouldn't happen with COUNT, but handle it)
|
f"vs event_logs.count={check_cycle_count}. "
|
||||||
cumulative_stats = {
|
f"Rebuilding cumulative stats from event logs..."
|
||||||
'total_check_cycles': 0,
|
)
|
||||||
'cumulative_tasks_found': 0,
|
# Rebuild cumulative stats from event logs
|
||||||
'cumulative_tasks_executed': 0,
|
cumulative_stats = _rebuild_cumulative_stats_from_events(db)
|
||||||
'cumulative_tasks_failed': 0
|
# Update the persistent table
|
||||||
}
|
if cumulative_stats_row:
|
||||||
logger.warning("[Dashboard] Query returned None (no check cycle events)")
|
cumulative_stats_row.total_check_cycles = cumulative_stats['total_check_cycles']
|
||||||
|
cumulative_stats_row.cumulative_tasks_found = cumulative_stats['cumulative_tasks_found']
|
||||||
|
cumulative_stats_row.cumulative_tasks_executed = cumulative_stats['cumulative_tasks_executed']
|
||||||
|
cumulative_stats_row.cumulative_tasks_failed = cumulative_stats['cumulative_tasks_failed']
|
||||||
|
cumulative_stats_row.cumulative_tasks_skipped = cumulative_stats.get('cumulative_tasks_skipped', 0)
|
||||||
|
db.commit()
|
||||||
|
logger.warning(f"[Dashboard] ✅ Rebuilt cumulative stats: {cumulative_stats}")
|
||||||
else:
|
else:
|
||||||
# No check cycles yet, but we can still show job counts
|
# Cumulative stats table doesn't exist or is empty, rebuild from event logs
|
||||||
# Log detailed info about why cumulative stats are 0
|
logger.warning(
|
||||||
if stats.get('total_checks', 0) > 0:
|
"[Dashboard] Cumulative stats table not found or empty. "
|
||||||
logger.warning(
|
"Rebuilding from event logs..."
|
||||||
f"[Dashboard] ⚠️ Scheduler shows {stats.get('total_checks', 0)} checks in memory, "
|
)
|
||||||
f"but NO check_cycle events found in database. "
|
cumulative_stats = _rebuild_cumulative_stats_from_events(db)
|
||||||
f"This suggests check_cycle events are not being saved properly."
|
|
||||||
)
|
# Create/update the persistent table
|
||||||
else:
|
cumulative_stats_row = SchedulerCumulativeStats.get_or_create(db)
|
||||||
logger.warning(
|
cumulative_stats_row.total_check_cycles = cumulative_stats['total_check_cycles']
|
||||||
f"[Dashboard] No check_cycle events yet. "
|
cumulative_stats_row.cumulative_tasks_found = cumulative_stats['cumulative_tasks_found']
|
||||||
f"Scheduler interval: {stats.get('check_interval_minutes', 60)}min. "
|
cumulative_stats_row.cumulative_tasks_executed = cumulative_stats['cumulative_tasks_executed']
|
||||||
f"First check cycle will run after interval expires. "
|
cumulative_stats_row.cumulative_tasks_failed = cumulative_stats['cumulative_tasks_failed']
|
||||||
f"One-time jobs: {job_completed_count} completed, {job_failed_count} failed"
|
cumulative_stats_row.cumulative_tasks_skipped = cumulative_stats.get('cumulative_tasks_skipped', 0)
|
||||||
)
|
db.commit()
|
||||||
|
logger.warning(f"[Dashboard] ✅ Created/updated cumulative stats: {cumulative_stats}")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Cumulative stats model doesn't exist yet (migration not run)
|
||||||
|
logger.warning(
|
||||||
|
"[Dashboard] Cumulative stats model not found. "
|
||||||
|
"Falling back to event logs aggregation. "
|
||||||
|
"Run migration: create_scheduler_cumulative_stats.sql"
|
||||||
|
)
|
||||||
|
cumulative_stats = _rebuild_cumulative_stats_from_events(db)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating cumulative stats: {e}", exc_info=True)
|
logger.error(f"[Dashboard] Error getting cumulative stats: {e}", exc_info=True)
|
||||||
cumulative_stats = {
|
# Fallback to event logs aggregation
|
||||||
'total_check_cycles': 0,
|
cumulative_stats = _rebuild_cumulative_stats_from_events(db)
|
||||||
'cumulative_tasks_found': 0,
|
|
||||||
'cumulative_tasks_executed': 0,
|
|
||||||
'cumulative_tasks_failed': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stats': {
|
'stats': {
|
||||||
@@ -259,8 +390,9 @@ async def get_scheduler_dashboard(
|
|||||||
},
|
},
|
||||||
'jobs': formatted_jobs,
|
'jobs': formatted_jobs,
|
||||||
'job_count': len(formatted_jobs),
|
'job_count': len(formatted_jobs),
|
||||||
'recurring_jobs': 1 + len([j for j in formatted_jobs if j.get('is_database_task')]), # check_due_tasks + OAuth tasks
|
'recurring_jobs': 1 + len([j for j in formatted_jobs if j.get('is_database_task')]), # check_due_tasks + all DB tasks
|
||||||
'one_time_jobs': len([j for j in formatted_jobs if not j.get('is_database_task') and j.get('trigger_type') == 'DateTrigger']),
|
'one_time_jobs': len([j for j in formatted_jobs if not j.get('is_database_task') and j.get('trigger_type') == 'DateTrigger']),
|
||||||
|
'registered_task_types': stats.get('registered_types', []), # Include registered task types
|
||||||
'user_isolation': {
|
'user_isolation': {
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
'current_user_id': user_id_str
|
'current_user_id': user_id_str
|
||||||
@@ -704,3 +836,381 @@ async def get_recent_scheduler_logs(
|
|||||||
logger.error(f"Error getting recent scheduler logs: {e}")
|
logger.error(f"Error getting recent scheduler logs: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get recent scheduler logs: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get recent scheduler logs: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/platform-insights/status/{user_id}")
|
||||||
|
async def get_platform_insights_status(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get platform insights task status for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- GSC insights tasks
|
||||||
|
- Bing insights tasks
|
||||||
|
- Task details and execution logs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify user can only access their own data
|
||||||
|
if str(current_user.get('id')) != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
logger.debug(f"[Platform Insights Status] Getting status for user: {user_id}")
|
||||||
|
|
||||||
|
# Get all insights tasks for user
|
||||||
|
tasks = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.user_id == user_id
|
||||||
|
).order_by(PlatformInsightsTask.platform, PlatformInsightsTask.created_at).all()
|
||||||
|
|
||||||
|
# Check if user has connected platforms but missing insights tasks
|
||||||
|
# Auto-create missing tasks for connected platforms
|
||||||
|
from services.oauth_token_monitoring_service import get_connected_platforms
|
||||||
|
from services.platform_insights_monitoring_service import create_platform_insights_task
|
||||||
|
|
||||||
|
connected_platforms = get_connected_platforms(user_id)
|
||||||
|
insights_platforms = ['gsc', 'bing']
|
||||||
|
connected_insights = [p for p in connected_platforms if p in insights_platforms]
|
||||||
|
|
||||||
|
existing_platforms = {task.platform for task in tasks}
|
||||||
|
missing_platforms = [p for p in connected_insights if p not in existing_platforms]
|
||||||
|
|
||||||
|
if missing_platforms:
|
||||||
|
logger.info(
|
||||||
|
f"[Platform Insights Status] User {user_id} has connected platforms {missing_platforms} "
|
||||||
|
f"but missing insights tasks. Creating tasks..."
|
||||||
|
)
|
||||||
|
|
||||||
|
for platform in missing_platforms:
|
||||||
|
try:
|
||||||
|
# Don't fetch site_url here - it requires API calls
|
||||||
|
# The executor will fetch it when the task runs
|
||||||
|
# Create task without site_url to avoid API calls during status checks
|
||||||
|
result = create_platform_insights_task(
|
||||||
|
user_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
site_url=None, # Will be fetched by executor when task runs
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.info(f"[Platform Insights Status] Created {platform.upper()} insights task for user {user_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Platform Insights Status] Failed to create {platform} task: {result.get('error')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Platform Insights Status] Error creating {platform} task: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Re-query tasks after creation
|
||||||
|
tasks = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.user_id == user_id
|
||||||
|
).order_by(PlatformInsightsTask.platform, PlatformInsightsTask.created_at).all()
|
||||||
|
|
||||||
|
# Group tasks by platform
|
||||||
|
gsc_tasks = [t for t in tasks if t.platform == 'gsc']
|
||||||
|
bing_tasks = [t for t in tasks if t.platform == 'bing']
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights Status] Found {len(tasks)} total tasks: "
|
||||||
|
f"{len(gsc_tasks)} GSC, {len(bing_tasks)} Bing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format tasks
|
||||||
|
def format_task(task: PlatformInsightsTask) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': task.id,
|
||||||
|
'platform': task.platform,
|
||||||
|
'site_url': task.site_url,
|
||||||
|
'status': task.status,
|
||||||
|
'last_check': task.last_check.isoformat() if task.last_check else None,
|
||||||
|
'last_success': task.last_success.isoformat() if task.last_success else None,
|
||||||
|
'last_failure': task.last_failure.isoformat() if task.last_failure else None,
|
||||||
|
'failure_reason': task.failure_reason,
|
||||||
|
'next_check': task.next_check.isoformat() if task.next_check else None,
|
||||||
|
'created_at': task.created_at.isoformat() if task.created_at else None,
|
||||||
|
'updated_at': task.updated_at.isoformat() if task.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'user_id': user_id,
|
||||||
|
'gsc_tasks': [format_task(t) for t in gsc_tasks],
|
||||||
|
'bing_tasks': [format_task(t) for t in bing_tasks],
|
||||||
|
'total_tasks': len(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting platform insights status for user {user_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get platform insights status: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/website-analysis/status/{user_id}")
|
||||||
|
async def get_website_analysis_status(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get website analysis task status for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- User website tasks
|
||||||
|
- Competitor website tasks
|
||||||
|
- Task details and execution logs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify user can only access their own data
|
||||||
|
if str(current_user.get('id')) != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
logger.debug(f"[Website Analysis Status] Getting status for user: {user_id}")
|
||||||
|
|
||||||
|
# Get all website analysis tasks for user
|
||||||
|
tasks = db.query(WebsiteAnalysisTask).filter(
|
||||||
|
WebsiteAnalysisTask.user_id == user_id
|
||||||
|
).order_by(WebsiteAnalysisTask.task_type, WebsiteAnalysisTask.created_at).all()
|
||||||
|
|
||||||
|
# Separate user website and competitor tasks
|
||||||
|
user_website_tasks = [t for t in tasks if t.task_type == 'user_website']
|
||||||
|
competitor_tasks = [t for t in tasks if t.task_type == 'competitor']
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[Website Analysis Status] Found {len(tasks)} tasks for user {user_id}: "
|
||||||
|
f"{len(user_website_tasks)} user website, {len(competitor_tasks)} competitors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format tasks
|
||||||
|
def format_task(task: WebsiteAnalysisTask) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': task.id,
|
||||||
|
'website_url': task.website_url,
|
||||||
|
'task_type': task.task_type,
|
||||||
|
'competitor_id': task.competitor_id,
|
||||||
|
'status': task.status,
|
||||||
|
'last_check': task.last_check.isoformat() if task.last_check else None,
|
||||||
|
'last_success': task.last_success.isoformat() if task.last_success else None,
|
||||||
|
'last_failure': task.last_failure.isoformat() if task.last_failure else None,
|
||||||
|
'failure_reason': task.failure_reason,
|
||||||
|
'next_check': task.next_check.isoformat() if task.next_check else None,
|
||||||
|
'frequency_days': task.frequency_days,
|
||||||
|
'created_at': task.created_at.isoformat() if task.created_at else None,
|
||||||
|
'updated_at': task.updated_at.isoformat() if task.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
active_tasks = len([t for t in tasks if t.status == 'active'])
|
||||||
|
failed_tasks = len([t for t in tasks if t.status == 'failed'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'user_id': user_id,
|
||||||
|
'user_website_tasks': [format_task(t) for t in user_website_tasks],
|
||||||
|
'competitor_tasks': [format_task(t) for t in competitor_tasks],
|
||||||
|
'total_tasks': len(tasks),
|
||||||
|
'active_tasks': active_tasks,
|
||||||
|
'failed_tasks': failed_tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting website analysis status for user {user_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get website analysis status: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/website-analysis/logs/{user_id}")
|
||||||
|
async def get_website_analysis_logs(
|
||||||
|
user_id: str,
|
||||||
|
task_id: Optional[int] = Query(None),
|
||||||
|
limit: int = Query(10, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get execution logs for website analysis tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
task_id: Optional task ID to filter logs
|
||||||
|
limit: Maximum number of logs to return
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution logs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify user can only access their own data
|
||||||
|
if str(current_user.get('id')) != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
query = db.query(WebsiteAnalysisExecutionLog).join(
|
||||||
|
WebsiteAnalysisTask,
|
||||||
|
WebsiteAnalysisExecutionLog.task_id == WebsiteAnalysisTask.id
|
||||||
|
).filter(
|
||||||
|
WebsiteAnalysisTask.user_id == user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
query = query.filter(WebsiteAnalysisExecutionLog.task_id == task_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total_count = query.count()
|
||||||
|
|
||||||
|
logs = query.order_by(
|
||||||
|
desc(WebsiteAnalysisExecutionLog.execution_date)
|
||||||
|
).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
# Format logs
|
||||||
|
formatted_logs = []
|
||||||
|
for log in logs:
|
||||||
|
# Get task details
|
||||||
|
task = db.query(WebsiteAnalysisTask).filter(WebsiteAnalysisTask.id == log.task_id).first()
|
||||||
|
|
||||||
|
formatted_logs.append({
|
||||||
|
'id': log.id,
|
||||||
|
'task_id': log.task_id,
|
||||||
|
'website_url': task.website_url if task else None,
|
||||||
|
'task_type': task.task_type if task else None,
|
||||||
|
'execution_date': log.execution_date.isoformat() if log.execution_date else None,
|
||||||
|
'status': log.status,
|
||||||
|
'result_data': log.result_data,
|
||||||
|
'error_message': log.error_message,
|
||||||
|
'execution_time_ms': log.execution_time_ms,
|
||||||
|
'created_at': log.created_at.isoformat() if log.created_at else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'logs': formatted_logs,
|
||||||
|
'total_count': total_count,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'has_more': (offset + limit) < total_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting website analysis logs for user {user_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get website analysis logs: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/website-analysis/retry/{task_id}")
|
||||||
|
async def retry_website_analysis(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually retry a failed website analysis task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID to retry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status and updated task details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get task
|
||||||
|
task = db.query(WebsiteAnalysisTask).filter(WebsiteAnalysisTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
# Verify user can only access their own tasks
|
||||||
|
if str(current_user.get('id')) != task.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Reset task status and schedule immediate execution
|
||||||
|
task.status = 'active'
|
||||||
|
task.failure_reason = None
|
||||||
|
task.next_check = datetime.utcnow() # Schedule immediately
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Manually retried website analysis task {task_id} for user {task.user_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Website analysis task {task_id} scheduled for immediate execution',
|
||||||
|
'task': {
|
||||||
|
'id': task.id,
|
||||||
|
'website_url': task.website_url,
|
||||||
|
'status': task.status,
|
||||||
|
'next_check': task.next_check.isoformat() if task.next_check else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrying website analysis task {task_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to retry website analysis: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/platform-insights/logs/{user_id}")
|
||||||
|
async def get_platform_insights_logs(
|
||||||
|
user_id: str,
|
||||||
|
task_id: Optional[int] = Query(None),
|
||||||
|
limit: int = Query(10, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get execution logs for platform insights tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
task_id: Optional task ID to filter logs
|
||||||
|
limit: Maximum number of logs to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution logs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify user can only access their own data
|
||||||
|
if str(current_user.get('id')) != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
query = db.query(PlatformInsightsExecutionLog).join(
|
||||||
|
PlatformInsightsTask,
|
||||||
|
PlatformInsightsExecutionLog.task_id == PlatformInsightsTask.id
|
||||||
|
).filter(
|
||||||
|
PlatformInsightsTask.user_id == user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
query = query.filter(PlatformInsightsExecutionLog.task_id == task_id)
|
||||||
|
|
||||||
|
logs = query.order_by(
|
||||||
|
desc(PlatformInsightsExecutionLog.execution_date)
|
||||||
|
).limit(limit).all()
|
||||||
|
|
||||||
|
def format_log(log: PlatformInsightsExecutionLog) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': log.id,
|
||||||
|
'task_id': log.task_id,
|
||||||
|
'execution_date': log.execution_date.isoformat() if log.execution_date else None,
|
||||||
|
'status': log.status,
|
||||||
|
'result_data': log.result_data,
|
||||||
|
'error_message': log.error_message,
|
||||||
|
'execution_time_ms': log.execution_time_ms,
|
||||||
|
'data_source': log.data_source,
|
||||||
|
'created_at': log.created_at.isoformat() if log.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'logs': [format_log(log) for log in logs],
|
||||||
|
'total_count': len(logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting platform insights logs for user {user_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get platform insights logs: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,24 @@ Handles Wix authentication, connection status, and blog publishing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services.wix_service import WixService
|
from services.wix_service import WixService
|
||||||
|
from services.integrations.wix_oauth import WixOAuthService
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
|
import os
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
|
router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
|
||||||
|
|
||||||
# Initialize Wix service
|
# Initialize Wix service
|
||||||
wix_service = WixService()
|
wix_service = WixService()
|
||||||
|
|
||||||
|
# Initialize Wix OAuth service for token storage
|
||||||
|
wix_oauth_service = WixOAuthService(db_path=os.path.abspath("alwrity.db"))
|
||||||
|
|
||||||
|
|
||||||
class WixAuthRequest(BaseModel):
|
class WixAuthRequest(BaseModel):
|
||||||
"""Request model for Wix authentication"""
|
"""Request model for Wix authentication"""
|
||||||
@@ -88,17 +94,41 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
|
|||||||
Token information and connection status
|
Token information and connection status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
user_id = current_user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
tokens = wix_service.exchange_code_for_tokens(request.code)
|
tokens = wix_service.exchange_code_for_tokens(request.code)
|
||||||
|
|
||||||
# Get site information
|
# Get site information to extract site_id and member_id
|
||||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||||
|
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||||
|
|
||||||
|
# Extract member_id from token if possible
|
||||||
|
member_id = None
|
||||||
|
try:
|
||||||
|
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||||
|
|
||||||
# TODO: Store tokens securely in database associated with current_user
|
# Store tokens securely in database
|
||||||
# For now, we'll return them (in production, store in encrypted database)
|
stored = wix_oauth_service.store_tokens(
|
||||||
|
user_id=user_id,
|
||||||
|
access_token=tokens['access_token'],
|
||||||
|
refresh_token=tokens.get('refresh_token'),
|
||||||
|
expires_in=tokens.get('expires_in'),
|
||||||
|
token_type=tokens.get('token_type', 'Bearer'),
|
||||||
|
scope=tokens.get('scope'),
|
||||||
|
site_id=site_id,
|
||||||
|
member_id=member_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not stored:
|
||||||
|
logger.warning(f"Failed to store Wix tokens for user {user_id}, but OAuth succeeded")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -125,6 +155,29 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
|
|||||||
tokens = wix_service.exchange_code_for_tokens(code)
|
tokens = wix_service.exchange_code_for_tokens(code)
|
||||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||||
|
|
||||||
|
# Store tokens in database if we have user_id
|
||||||
|
user_id = current_user.get('id') if current_user else None
|
||||||
|
if user_id:
|
||||||
|
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||||
|
member_id = None
|
||||||
|
try:
|
||||||
|
member_id = wix_service.extract_member_id_from_access_token(tokens['access_token'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stored = wix_oauth_service.store_tokens(
|
||||||
|
user_id=user_id,
|
||||||
|
access_token=tokens['access_token'],
|
||||||
|
refresh_token=tokens.get('refresh_token'),
|
||||||
|
expires_in=tokens.get('expires_in'),
|
||||||
|
token_type=tokens.get('token_type', 'Bearer'),
|
||||||
|
scope=tokens.get('scope'),
|
||||||
|
site_id=site_id,
|
||||||
|
member_id=member_id
|
||||||
|
)
|
||||||
|
if not stored:
|
||||||
|
logger.warning(f"Failed to store Wix tokens for user {user_id} in GET callback")
|
||||||
|
|
||||||
# Build success payload for postMessage
|
# Build success payload for postMessage
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any, Union
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ class ResearchProvider(str, Enum):
|
|||||||
"""Research provider options."""
|
"""Research provider options."""
|
||||||
GOOGLE = "google" # Gemini native grounding
|
GOOGLE = "google" # Gemini native grounding
|
||||||
EXA = "exa" # Exa neural search
|
EXA = "exa" # Exa neural search
|
||||||
|
TAVILY = "tavily" # Tavily AI-powered search
|
||||||
|
|
||||||
|
|
||||||
class ResearchConfig(BaseModel):
|
class ResearchConfig(BaseModel):
|
||||||
@@ -100,6 +101,23 @@ class ResearchConfig(BaseModel):
|
|||||||
exa_include_domains: List[str] = [] # Domain whitelist
|
exa_include_domains: List[str] = [] # Domain whitelist
|
||||||
exa_exclude_domains: List[str] = [] # Domain blacklist
|
exa_exclude_domains: List[str] = [] # Domain blacklist
|
||||||
exa_search_type: Optional[str] = "auto" # "auto", "keyword", "neural"
|
exa_search_type: Optional[str] = "auto" # "auto", "keyword", "neural"
|
||||||
|
|
||||||
|
# Tavily-specific options
|
||||||
|
tavily_topic: Optional[str] = "general" # general, news, finance
|
||||||
|
tavily_search_depth: Optional[str] = "basic" # basic (1 credit), advanced (2 credits)
|
||||||
|
tavily_include_domains: List[str] = [] # Domain whitelist (max 300)
|
||||||
|
tavily_exclude_domains: List[str] = [] # Domain blacklist (max 150)
|
||||||
|
tavily_include_answer: Union[bool, str] = False # basic, advanced, true, false
|
||||||
|
tavily_include_raw_content: Union[bool, str] = False # markdown, text, true, false
|
||||||
|
tavily_include_images: bool = False
|
||||||
|
tavily_include_image_descriptions: bool = False
|
||||||
|
tavily_include_favicon: bool = False
|
||||||
|
tavily_time_range: Optional[str] = None # day, week, month, year, d, w, m, y
|
||||||
|
tavily_start_date: Optional[str] = None # YYYY-MM-DD
|
||||||
|
tavily_end_date: Optional[str] = None # YYYY-MM-DD
|
||||||
|
tavily_country: Optional[str] = None # Country code (only for general topic)
|
||||||
|
tavily_chunks_per_source: int = 3 # 1-3 (only for advanced search)
|
||||||
|
tavily_auto_parameters: bool = False # Auto-configure parameters based on query
|
||||||
|
|
||||||
|
|
||||||
class BlogResearchRequest(BaseModel):
|
class BlogResearchRequest(BaseModel):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class OnboardingSession(Base):
|
|||||||
website_analyses = relationship('WebsiteAnalysis', back_populates='session', cascade="all, delete-orphan")
|
website_analyses = relationship('WebsiteAnalysis', back_populates='session', cascade="all, delete-orphan")
|
||||||
research_preferences = relationship('ResearchPreferences', back_populates='session', cascade="all, delete-orphan", uselist=False)
|
research_preferences = relationship('ResearchPreferences', back_populates='session', cascade="all, delete-orphan", uselist=False)
|
||||||
persona_data = relationship('PersonaData', back_populates='session', cascade="all, delete-orphan", uselist=False)
|
persona_data = relationship('PersonaData', back_populates='session', cascade="all, delete-orphan", uselist=False)
|
||||||
|
competitor_analyses = relationship('CompetitorAnalysis', back_populates='session', cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<OnboardingSession(id={self.id}, user_id={self.user_id}, step={self.current_step}, progress={self.progress})>"
|
return f"<OnboardingSession(id={self.id}, user_id={self.user_id}, step={self.current_step}, progress={self.progress})>"
|
||||||
@@ -188,4 +189,46 @@ class PersonaData(Base):
|
|||||||
'research_persona_generated_at': self.research_persona_generated_at.isoformat() if self.research_persona_generated_at else None,
|
'research_persona_generated_at': self.research_persona_generated_at.isoformat() if self.research_persona_generated_at else None,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompetitorAnalysis(Base):
|
||||||
|
"""Stores competitor website analysis results from scheduled analysis tasks."""
|
||||||
|
__tablename__ = 'competitor_analyses'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id = Column(Integer, ForeignKey('onboarding_sessions.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
competitor_url = Column(String(500), nullable=False)
|
||||||
|
competitor_domain = Column(String(255), nullable=True) # Extracted domain for easier queries
|
||||||
|
analysis_date = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
# Complete analysis data (same structure as WebsiteAnalysis)
|
||||||
|
analysis_data = Column(JSON) # Contains style_analysis, crawl_result, style_patterns, style_guidelines
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
status = Column(String(50), default='completed') # completed, failed, in_progress
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
warning_message = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
session = relationship('OnboardingSession', back_populates='competitor_analyses')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<CompetitorAnalysis(id={self.id}, url={self.competitor_url}, status={self.status})>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary for API responses."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'session_id': self.session_id,
|
||||||
|
'competitor_url': self.competitor_url,
|
||||||
|
'competitor_domain': self.competitor_domain,
|
||||||
|
'analysis_date': self.analysis_date.isoformat() if self.analysis_date else None,
|
||||||
|
'analysis_data': self.analysis_data,
|
||||||
|
'status': self.status,
|
||||||
|
'error_message': self.error_message,
|
||||||
|
'warning_message': self.warning_message,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
}
|
}
|
||||||
100
backend/models/platform_insights_monitoring_models.py
Normal file
100
backend/models/platform_insights_monitoring_models.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Platform Insights Monitoring Models
|
||||||
|
Database models for tracking platform insights (GSC/Bing) fetch tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Index, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Import the same Base from enhanced_strategy_models
|
||||||
|
from models.enhanced_strategy_models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformInsightsTask(Base):
|
||||||
|
"""
|
||||||
|
Model for storing platform insights fetch tasks.
|
||||||
|
|
||||||
|
Tracks per-user, per-platform insights fetching with weekly updates.
|
||||||
|
"""
|
||||||
|
__tablename__ = "platform_insights_tasks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# User and Platform Identification
|
||||||
|
user_id = Column(String(255), nullable=False, index=True) # Clerk user ID (string)
|
||||||
|
platform = Column(String(50), nullable=False) # 'gsc' or 'bing'
|
||||||
|
site_url = Column(String(500), nullable=True) # Optional: specific site URL
|
||||||
|
|
||||||
|
# Task Status
|
||||||
|
status = Column(String(50), default='active') # 'active', 'failed', 'paused'
|
||||||
|
|
||||||
|
# Execution Tracking
|
||||||
|
last_check = Column(DateTime, nullable=True)
|
||||||
|
last_success = Column(DateTime, nullable=True)
|
||||||
|
last_failure = Column(DateTime, nullable=True)
|
||||||
|
failure_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Execution Logs Relationship
|
||||||
|
execution_logs = relationship(
|
||||||
|
"PlatformInsightsExecutionLog",
|
||||||
|
back_populates="task",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for efficient queries
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_platform_insights_user_platform', 'user_id', 'platform'),
|
||||||
|
Index('idx_platform_insights_next_check', 'next_check'),
|
||||||
|
Index('idx_platform_insights_status', 'status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlatformInsightsTask(id={self.id}, user_id={self.user_id}, platform={self.platform}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformInsightsExecutionLog(Base):
|
||||||
|
"""
|
||||||
|
Model for storing platform insights fetch execution logs.
|
||||||
|
|
||||||
|
Tracks individual execution attempts with results and error details.
|
||||||
|
"""
|
||||||
|
__tablename__ = "platform_insights_execution_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Task Reference
|
||||||
|
task_id = Column(Integer, ForeignKey("platform_insights_tasks.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Execution Details
|
||||||
|
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
status = Column(String(50), nullable=False) # 'success', 'failed', 'skipped'
|
||||||
|
|
||||||
|
# Results
|
||||||
|
result_data = Column(JSON, nullable=True) # Insights data, metrics, etc.
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
execution_time_ms = Column(Integer, nullable=True)
|
||||||
|
data_source = Column(String(50), nullable=True) # 'cached', 'api', 'onboarding'
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationship to task
|
||||||
|
task = relationship("PlatformInsightsTask", back_populates="execution_logs")
|
||||||
|
|
||||||
|
# Indexes for efficient queries
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_platform_insights_log_task_execution_date', 'task_id', 'execution_date'),
|
||||||
|
Index('idx_platform_insights_log_status', 'status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlatformInsightsExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||||
|
|
||||||
48
backend/models/scheduler_cumulative_stats_model.py
Normal file
48
backend/models/scheduler_cumulative_stats_model.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Scheduler Cumulative Stats Model
|
||||||
|
Model for storing persistent cumulative scheduler metrics that survive restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, DateTime, Index
|
||||||
|
from datetime import datetime
|
||||||
|
from models.enhanced_strategy_models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerCumulativeStats(Base):
|
||||||
|
"""Model for storing cumulative scheduler metrics that persist across restarts"""
|
||||||
|
__tablename__ = "scheduler_cumulative_stats"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, default=1) # Always use id=1
|
||||||
|
total_check_cycles = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_tasks_found = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_tasks_executed = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_tasks_failed = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_tasks_skipped = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_job_completed = Column(Integer, default=0, nullable=False)
|
||||||
|
cumulative_job_failed = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
last_updated = Column(DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow)
|
||||||
|
last_check_cycle_id = Column(Integer, nullable=True) # Reference to last check_cycle event log ID
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_scheduler_cumulative_stats_single_row', 'id', unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create(cls, db_session):
|
||||||
|
"""
|
||||||
|
Get the cumulative stats row (id=1) or create it if it doesn't exist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SchedulerCumulativeStats instance
|
||||||
|
"""
|
||||||
|
stats = db_session.query(cls).filter(cls.id == 1).first()
|
||||||
|
if not stats:
|
||||||
|
stats = cls(id=1)
|
||||||
|
db_session.add(stats)
|
||||||
|
db_session.commit()
|
||||||
|
return stats
|
||||||
|
|
||||||
105
backend/models/website_analysis_monitoring_models.py
Normal file
105
backend/models/website_analysis_monitoring_models.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
Website Analysis Monitoring Models
|
||||||
|
Database models for tracking website analysis tasks and execution logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, JSON, Index, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Import the same Base from enhanced_strategy_models
|
||||||
|
from models.enhanced_strategy_models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteAnalysisTask(Base):
|
||||||
|
"""
|
||||||
|
Model for storing website analysis monitoring tasks.
|
||||||
|
|
||||||
|
Tracks per-user, per-URL website analysis with recurring checks.
|
||||||
|
"""
|
||||||
|
__tablename__ = "website_analysis_tasks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# User and URL Identification
|
||||||
|
user_id = Column(String(255), nullable=False, index=True) # Clerk user ID (string)
|
||||||
|
website_url = Column(String(500), nullable=False) # URL to analyze
|
||||||
|
task_type = Column(String(50), nullable=False) # 'user_website' or 'competitor'
|
||||||
|
competitor_id = Column(String(255), nullable=True) # For competitor tasks (domain or identifier)
|
||||||
|
|
||||||
|
# Task Status
|
||||||
|
status = Column(String(50), default='active') # 'active', 'failed', 'paused'
|
||||||
|
|
||||||
|
# Execution Tracking
|
||||||
|
last_check = Column(DateTime, nullable=True)
|
||||||
|
last_success = Column(DateTime, nullable=True)
|
||||||
|
last_failure = Column(DateTime, nullable=True)
|
||||||
|
failure_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
|
||||||
|
frequency_days = Column(Integer, default=10) # Recurring frequency in days
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Execution Logs Relationship
|
||||||
|
execution_logs = relationship(
|
||||||
|
"WebsiteAnalysisExecutionLog",
|
||||||
|
back_populates="task",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for efficient queries
|
||||||
|
# Note: Index names match migration script to avoid conflicts
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_website_analysis_tasks_user_url', 'user_id', 'website_url'),
|
||||||
|
Index('idx_website_analysis_tasks_user_task_type', 'user_id', 'task_type'),
|
||||||
|
Index('idx_website_analysis_tasks_next_check', 'next_check'),
|
||||||
|
Index('idx_website_analysis_tasks_status', 'status'),
|
||||||
|
Index('idx_website_analysis_tasks_task_type', 'task_type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<WebsiteAnalysisTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, type={self.task_type}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteAnalysisExecutionLog(Base):
|
||||||
|
"""
|
||||||
|
Model for storing website analysis execution logs.
|
||||||
|
|
||||||
|
Tracks individual execution attempts with results and error details.
|
||||||
|
"""
|
||||||
|
__tablename__ = "website_analysis_execution_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Task Reference
|
||||||
|
task_id = Column(Integer, ForeignKey("website_analysis_tasks.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Execution Details
|
||||||
|
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
status = Column(String(50), nullable=False) # 'success', 'failed', 'skipped', 'running'
|
||||||
|
|
||||||
|
# Results
|
||||||
|
result_data = Column(JSON, nullable=True) # Analysis results (style_analysis, crawl_result, etc.)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
execution_time_ms = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationship to task
|
||||||
|
task = relationship("WebsiteAnalysisTask", back_populates="execution_logs")
|
||||||
|
|
||||||
|
# Indexes for efficient queries
|
||||||
|
# Note: Index names match migration script to avoid conflicts
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_website_analysis_execution_logs_task_execution_date', 'task_id', 'execution_date'),
|
||||||
|
Index('idx_website_analysis_execution_logs_status', 'status'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<WebsiteAnalysisExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||||
|
|
||||||
@@ -160,6 +160,43 @@ async def handle_bing_callback(
|
|||||||
"""
|
"""
|
||||||
return HTMLResponse(content=html_content)
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
# Create Bing insights task immediately after successful connection
|
||||||
|
try:
|
||||||
|
from services.database import SessionLocal
|
||||||
|
from services.platform_insights_monitoring_service import create_platform_insights_task
|
||||||
|
|
||||||
|
# Get user_id from state (stored during OAuth flow)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Get user_id from Bing OAuth service state lookup
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(oauth_service.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT user_id FROM bing_oauth_states WHERE state = ?', (state,))
|
||||||
|
result_db = cursor.fetchone()
|
||||||
|
if result_db:
|
||||||
|
user_id = result_db[0]
|
||||||
|
|
||||||
|
# Don't fetch site_url here - it requires API calls
|
||||||
|
# The executor will fetch it when the task runs (weekly)
|
||||||
|
# Create insights task without site_url to avoid API calls
|
||||||
|
task_result = create_platform_insights_task(
|
||||||
|
user_id=user_id,
|
||||||
|
platform='bing',
|
||||||
|
site_url=None, # Will be fetched by executor when task runs
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if task_result.get('success'):
|
||||||
|
logger.info(f"Created Bing insights task for user {user_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to create Bing insights task: {task_result.get('error')}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
# Non-critical: log but don't fail OAuth callback
|
||||||
|
logger.warning(f"Failed to create Bing insights task after OAuth: {e}")
|
||||||
|
|
||||||
# Return success page with postMessage script
|
# Return success page with postMessage script
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -66,6 +66,45 @@ async def handle_gsc_callback(
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info("GSC OAuth callback handled successfully")
|
logger.info("GSC OAuth callback handled successfully")
|
||||||
|
|
||||||
|
# Create GSC insights task immediately after successful connection
|
||||||
|
try:
|
||||||
|
from services.database import SessionLocal
|
||||||
|
from services.platform_insights_monitoring_service import create_platform_insights_task
|
||||||
|
|
||||||
|
# Get user_id from state (stored during OAuth flow)
|
||||||
|
# Note: handle_oauth_callback already deleted state, so we need to get user_id from recent credentials
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Get user_id from most recent GSC credentials (since state was deleted)
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(gsc_service.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT user_id FROM gsc_credentials ORDER BY updated_at DESC LIMIT 1')
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
user_id = result[0]
|
||||||
|
|
||||||
|
# Don't fetch site_url here - it requires API calls
|
||||||
|
# The executor will fetch it when the task runs (weekly)
|
||||||
|
# Create insights task without site_url to avoid API calls
|
||||||
|
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()
|
||||||
|
except Exception as e:
|
||||||
|
# Non-critical: log but don't fail OAuth callback
|
||||||
|
logger.warning(f"Failed to create GSC insights task after OAuth: {e}", exc_info=True)
|
||||||
|
|
||||||
html = """
|
html = """
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|||||||
90
backend/scripts/fix_website_analysis_indexes.py
Normal file
90
backend/scripts/fix_website_analysis_indexes.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix website analysis index name conflicts.
|
||||||
|
Drops old conflicting indexes and ensures proper index names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Add the backend directory to the Python path
|
||||||
|
backend_dir = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(backend_dir))
|
||||||
|
|
||||||
|
def fix_indexes():
|
||||||
|
"""Fix index name conflicts."""
|
||||||
|
db_path = backend_dir / "alwrity.db"
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.error(f"Database not found at {db_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for old conflicting indexes
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name, tbl_name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type='index'
|
||||||
|
AND name = 'idx_status'
|
||||||
|
AND tbl_name IN ('website_analysis_tasks', 'website_analysis_execution_logs')
|
||||||
|
""")
|
||||||
|
|
||||||
|
conflicting = cursor.fetchall()
|
||||||
|
|
||||||
|
if conflicting:
|
||||||
|
logger.warning(f"Found {len(conflicting)} conflicting indexes:")
|
||||||
|
for name, tbl_name in conflicting:
|
||||||
|
logger.warning(f" - {name} on {tbl_name}")
|
||||||
|
|
||||||
|
# Drop old indexes
|
||||||
|
for name, tbl_name in conflicting:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"DROP INDEX IF EXISTS {name}")
|
||||||
|
logger.info(f"✅ Dropped old index: {name} on {tbl_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error dropping index {name}: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("✅ Index conflicts resolved")
|
||||||
|
else:
|
||||||
|
logger.info("✅ No conflicting indexes found")
|
||||||
|
|
||||||
|
# Verify correct indexes exist
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name, tbl_name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type='index'
|
||||||
|
AND (name LIKE '%website_analysis%' OR name LIKE '%competitor_analyses%')
|
||||||
|
ORDER BY tbl_name, name
|
||||||
|
""")
|
||||||
|
|
||||||
|
indexes = cursor.fetchall()
|
||||||
|
logger.info(f"\n📋 Current website analysis indexes ({len(indexes)}):")
|
||||||
|
for name, tbl_name in indexes:
|
||||||
|
logger.info(f" - {name} on {tbl_name}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fixing indexes: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("🔧 Fixing website analysis index conflicts...")
|
||||||
|
success = fix_indexes()
|
||||||
|
if success:
|
||||||
|
logger.info("✅ Index fix complete. You can now restart the backend.")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
logger.error("❌ Index fix failed")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
35
backend/scripts/run_cumulative_stats_migration.py
Normal file
35
backend/scripts/run_cumulative_stats_migration.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to run the cumulative stats migration.
|
||||||
|
This creates the scheduler_cumulative_stats table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Get the database path
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
backend_dir = os.path.dirname(script_dir)
|
||||||
|
db_path = os.path.join(backend_dir, 'alwrity.db')
|
||||||
|
migration_path = os.path.join(backend_dir, 'database', 'migrations', 'create_scheduler_cumulative_stats.sql')
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"❌ Database not found at {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(migration_path):
|
||||||
|
print(f"❌ Migration file not found at {migration_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
with open(migration_path, 'r') as f:
|
||||||
|
conn.executescript(f.read())
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Migration executed successfully")
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error running migration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
30
backend/scripts/verify_cumulative_stats.py
Normal file
30
backend/scripts/verify_cumulative_stats.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify cumulative stats table exists and has data"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
backend_dir = os.path.dirname(script_dir)
|
||||||
|
db_path = os.path.join(backend_dir, 'alwrity.db')
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scheduler_cumulative_stats'")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
print(f"Table exists: {result is not None}")
|
||||||
|
|
||||||
|
if result:
|
||||||
|
cursor.execute("SELECT * FROM scheduler_cumulative_stats WHERE id=1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
print(f"Row data: {row}")
|
||||||
|
else:
|
||||||
|
print("Table exists but no row with id=1")
|
||||||
|
else:
|
||||||
|
print("Table does not exist")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ from .data_filter import ResearchDataFilter
|
|||||||
from .base_provider import ResearchProvider as BaseResearchProvider
|
from .base_provider import ResearchProvider as BaseResearchProvider
|
||||||
from .google_provider import GoogleResearchProvider
|
from .google_provider import GoogleResearchProvider
|
||||||
from .exa_provider import ExaResearchProvider
|
from .exa_provider import ExaResearchProvider
|
||||||
|
from .tavily_provider import TavilyResearchProvider
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ResearchService',
|
'ResearchService',
|
||||||
@@ -26,4 +27,5 @@ __all__ = [
|
|||||||
'BaseResearchProvider',
|
'BaseResearchProvider',
|
||||||
'GoogleResearchProvider',
|
'GoogleResearchProvider',
|
||||||
'ExaResearchProvider',
|
'ExaResearchProvider',
|
||||||
|
'TavilyResearchProvider',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -150,8 +150,94 @@ class ResearchService:
|
|||||||
raw_result = None
|
raw_result = None
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
elif config.provider == ResearchProvider.TAVILY:
|
||||||
|
# Tavily research workflow
|
||||||
|
from .tavily_provider import TavilyResearchProvider
|
||||||
|
from services.database import get_db
|
||||||
|
from services.subscription import PricingService
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
if config.provider != ResearchProvider.EXA:
|
# Pre-flight validation (similar to Exa)
|
||||||
|
db_val = next(get_db())
|
||||||
|
try:
|
||||||
|
pricing_service = PricingService(db_val)
|
||||||
|
# Check Tavily usage limits
|
||||||
|
limits = pricing_service.get_user_limits(user_id)
|
||||||
|
tavily_limit = limits.get('limits', {}).get('tavily_calls', 0) if limits else 0
|
||||||
|
|
||||||
|
# Get current usage
|
||||||
|
from models.subscription_models import UsageSummary
|
||||||
|
from datetime import datetime
|
||||||
|
current_period = pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
|
||||||
|
usage = db_val.query(UsageSummary).filter(
|
||||||
|
UsageSummary.user_id == user_id,
|
||||||
|
UsageSummary.billing_period == current_period
|
||||||
|
).first()
|
||||||
|
|
||||||
|
current_calls = getattr(usage, 'tavily_calls', 0) or 0 if usage else 0
|
||||||
|
|
||||||
|
if tavily_limit > 0 and current_calls >= tavily_limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail={
|
||||||
|
'error': 'Tavily API call limit exceeded',
|
||||||
|
'message': f'You have reached your Tavily API call limit ({tavily_limit} calls). Please upgrade your plan or wait for the next billing period.',
|
||||||
|
'provider': 'tavily',
|
||||||
|
'usage_info': {
|
||||||
|
'current': current_calls,
|
||||||
|
'limit': tavily_limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking Tavily limits: {e}")
|
||||||
|
finally:
|
||||||
|
db_val.close()
|
||||||
|
|
||||||
|
# Execute Tavily search
|
||||||
|
api_start_time = time.time()
|
||||||
|
try:
|
||||||
|
tavily_provider = TavilyResearchProvider()
|
||||||
|
raw_result = await tavily_provider.search(
|
||||||
|
research_prompt, topic, industry, target_audience, config, user_id
|
||||||
|
)
|
||||||
|
api_duration_ms = (time.time() - api_start_time) * 1000
|
||||||
|
|
||||||
|
# Track usage
|
||||||
|
cost = raw_result.get('cost', {}).get('total', 0.001) if isinstance(raw_result.get('cost'), dict) else 0.001
|
||||||
|
search_depth = config.tavily_search_depth or "basic"
|
||||||
|
tavily_provider.track_tavily_usage(user_id, cost, search_depth)
|
||||||
|
|
||||||
|
# Log API call performance
|
||||||
|
blog_writer_logger.log_api_call(
|
||||||
|
"tavily_search",
|
||||||
|
"search",
|
||||||
|
api_duration_ms,
|
||||||
|
token_usage={},
|
||||||
|
content_length=len(raw_result.get('content', ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract content for downstream analysis
|
||||||
|
content = raw_result.get('content', '')
|
||||||
|
sources = raw_result.get('sources', [])
|
||||||
|
search_widget = "" # Tavily doesn't provide search widgets
|
||||||
|
search_queries = raw_result.get('search_queries', [])
|
||||||
|
grounding_metadata = None # Tavily doesn't provide grounding metadata
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "TAVILY_API_KEY not configured" in str(e):
|
||||||
|
logger.warning("Tavily not configured, falling back to Google")
|
||||||
|
config.provider = ResearchProvider.GOOGLE
|
||||||
|
# Continue to Google flow below
|
||||||
|
raw_result = None
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if config.provider not in [ResearchProvider.EXA, ResearchProvider.TAVILY]:
|
||||||
# Google research (existing flow) or fallback from Exa
|
# Google research (existing flow) or fallback from Exa
|
||||||
from .google_provider import GoogleResearchProvider
|
from .google_provider import GoogleResearchProvider
|
||||||
import time
|
import time
|
||||||
@@ -412,8 +498,94 @@ class ResearchService:
|
|||||||
# Continue to Google flow below
|
# Continue to Google flow below
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
elif config.provider == ResearchProvider.TAVILY:
|
||||||
|
# Tavily research workflow
|
||||||
|
from .tavily_provider import TavilyResearchProvider
|
||||||
|
from services.database import get_db
|
||||||
|
from services.subscription import PricingService
|
||||||
|
import os
|
||||||
|
|
||||||
if config.provider != ResearchProvider.EXA:
|
await task_manager.update_progress(task_id, "🌐 Connecting to Tavily AI search...")
|
||||||
|
|
||||||
|
# Pre-flight validation
|
||||||
|
db_val = next(get_db())
|
||||||
|
try:
|
||||||
|
pricing_service = PricingService(db_val)
|
||||||
|
# Check Tavily usage limits
|
||||||
|
limits = pricing_service.get_user_limits(user_id)
|
||||||
|
tavily_limit = limits.get('limits', {}).get('tavily_calls', 0) if limits else 0
|
||||||
|
|
||||||
|
# Get current usage
|
||||||
|
from models.subscription_models import UsageSummary
|
||||||
|
from datetime import datetime
|
||||||
|
current_period = pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
|
||||||
|
usage = db_val.query(UsageSummary).filter(
|
||||||
|
UsageSummary.user_id == user_id,
|
||||||
|
UsageSummary.billing_period == current_period
|
||||||
|
).first()
|
||||||
|
|
||||||
|
current_calls = getattr(usage, 'tavily_calls', 0) or 0 if usage else 0
|
||||||
|
|
||||||
|
if tavily_limit > 0 and current_calls >= tavily_limit:
|
||||||
|
await task_manager.update_progress(task_id, f"❌ Tavily API call limit exceeded ({current_calls}/{tavily_limit})")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail={
|
||||||
|
'error': 'Tavily API call limit exceeded',
|
||||||
|
'message': f'You have reached your Tavily API call limit ({tavily_limit} calls). Please upgrade your plan or wait for the next billing period.',
|
||||||
|
'provider': 'tavily',
|
||||||
|
'usage_info': {
|
||||||
|
'current': current_calls,
|
||||||
|
'limit': tavily_limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking Tavily limits: {e}")
|
||||||
|
finally:
|
||||||
|
db_val.close()
|
||||||
|
|
||||||
|
# Execute Tavily search
|
||||||
|
await task_manager.update_progress(task_id, "🤖 Executing Tavily AI search...")
|
||||||
|
try:
|
||||||
|
tavily_provider = TavilyResearchProvider()
|
||||||
|
raw_result = await tavily_provider.search(
|
||||||
|
research_prompt, topic, industry, target_audience, config, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track usage
|
||||||
|
cost = raw_result.get('cost', {}).get('total', 0.001) if isinstance(raw_result.get('cost'), dict) else 0.001
|
||||||
|
search_depth = config.tavily_search_depth or "basic"
|
||||||
|
tavily_provider.track_tavily_usage(user_id, cost, search_depth)
|
||||||
|
|
||||||
|
# Extract content for downstream analysis
|
||||||
|
if raw_result is None:
|
||||||
|
logger.error("raw_result is None after Tavily search")
|
||||||
|
raise ValueError("Tavily research result is None - search operation failed unexpectedly")
|
||||||
|
|
||||||
|
if not isinstance(raw_result, dict):
|
||||||
|
logger.warning(f"raw_result is not a dict (type: {type(raw_result)}), using defaults")
|
||||||
|
raw_result = {}
|
||||||
|
|
||||||
|
content = raw_result.get('content', '')
|
||||||
|
sources = raw_result.get('sources', []) or []
|
||||||
|
search_widget = "" # Tavily doesn't provide search widgets
|
||||||
|
search_queries = raw_result.get('search_queries', []) or []
|
||||||
|
grounding_metadata = None # Tavily doesn't provide grounding metadata
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "TAVILY_API_KEY not configured" in str(e):
|
||||||
|
logger.warning("Tavily not configured, falling back to Google")
|
||||||
|
await task_manager.update_progress(task_id, "⚠️ Tavily not configured, falling back to Google Search")
|
||||||
|
config.provider = ResearchProvider.GOOGLE
|
||||||
|
# Continue to Google flow below
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if config.provider not in [ResearchProvider.EXA, ResearchProvider.TAVILY]:
|
||||||
# Google research (existing flow)
|
# Google research (existing flow)
|
||||||
from .google_provider import GoogleResearchProvider
|
from .google_provider import GoogleResearchProvider
|
||||||
|
|
||||||
|
|||||||
169
backend/services/blog_writer/research/tavily_provider.py
Normal file
169
backend/services/blog_writer/research/tavily_provider.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Tavily Research Provider
|
||||||
|
|
||||||
|
AI-powered search implementation using Tavily API for high-quality research.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from loguru import logger
|
||||||
|
from models.subscription_models import APIProvider
|
||||||
|
from services.research.tavily_service import TavilyService
|
||||||
|
from .base_provider import ResearchProvider as BaseProvider
|
||||||
|
|
||||||
|
|
||||||
|
class TavilyResearchProvider(BaseProvider):
|
||||||
|
"""Tavily AI-powered search provider."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = os.getenv("TAVILY_API_KEY")
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("TAVILY_API_KEY not configured")
|
||||||
|
self.tavily_service = TavilyService()
|
||||||
|
logger.info("✅ Tavily Research Provider initialized")
|
||||||
|
|
||||||
|
async def search(self, prompt, topic, industry, target_audience, config, user_id):
|
||||||
|
"""Execute Tavily search and return standardized results."""
|
||||||
|
# Build Tavily query
|
||||||
|
query = f"{topic} {industry} {target_audience}"
|
||||||
|
|
||||||
|
# Get Tavily-specific config options
|
||||||
|
topic = config.tavily_topic or "general"
|
||||||
|
search_depth = config.tavily_search_depth or "basic"
|
||||||
|
|
||||||
|
logger.info(f"[Tavily Research] Executing search: {query}")
|
||||||
|
|
||||||
|
# Execute Tavily search
|
||||||
|
result = await self.tavily_service.search(
|
||||||
|
query=query,
|
||||||
|
topic=topic,
|
||||||
|
search_depth=search_depth,
|
||||||
|
max_results=min(config.max_sources, 20),
|
||||||
|
include_domains=config.tavily_include_domains or None,
|
||||||
|
exclude_domains=config.tavily_exclude_domains or None,
|
||||||
|
include_answer=config.tavily_include_answer or False,
|
||||||
|
include_raw_content=config.tavily_include_raw_content or False,
|
||||||
|
include_images=config.tavily_include_images or False,
|
||||||
|
include_image_descriptions=config.tavily_include_image_descriptions or False,
|
||||||
|
time_range=config.tavily_time_range,
|
||||||
|
start_date=config.tavily_start_date,
|
||||||
|
end_date=config.tavily_end_date,
|
||||||
|
country=config.tavily_country,
|
||||||
|
chunks_per_source=config.tavily_chunks_per_source or 3,
|
||||||
|
auto_parameters=config.tavily_auto_parameters or False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
raise RuntimeError(f"Tavily search failed: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
|
# Transform to standardized format
|
||||||
|
sources = self._transform_sources(result.get("results", []))
|
||||||
|
content = self._aggregate_content(result.get("results", []))
|
||||||
|
|
||||||
|
# Calculate cost (basic = 1 credit, advanced = 2 credits)
|
||||||
|
cost = 0.001 if search_depth == "basic" else 0.002 # Estimate cost per search
|
||||||
|
|
||||||
|
logger.info(f"[Tavily Research] Search completed: {len(sources)} sources, depth: {search_depth}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'sources': sources,
|
||||||
|
'content': content,
|
||||||
|
'search_type': search_depth,
|
||||||
|
'provider': 'tavily',
|
||||||
|
'search_queries': [query],
|
||||||
|
'cost': {'total': cost},
|
||||||
|
'answer': result.get("answer"), # If include_answer was requested
|
||||||
|
'images': result.get("images", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_provider_enum(self):
|
||||||
|
"""Return TAVILY provider enum for subscription tracking."""
|
||||||
|
return APIProvider.TAVILY
|
||||||
|
|
||||||
|
def estimate_tokens(self) -> int:
|
||||||
|
"""Estimate token usage for Tavily (not token-based, but we estimate API calls)."""
|
||||||
|
return 0 # Tavily is per-search, not token-based
|
||||||
|
|
||||||
|
def _transform_sources(self, results):
|
||||||
|
"""Transform Tavily results to ResearchSource format."""
|
||||||
|
sources = []
|
||||||
|
for idx, result in enumerate(results):
|
||||||
|
source_type = self._determine_source_type(result.get("url", ""))
|
||||||
|
|
||||||
|
sources.append({
|
||||||
|
'title': result.get("title", ""),
|
||||||
|
'url': result.get("url", ""),
|
||||||
|
'excerpt': result.get("content", "")[:500], # First 500 chars
|
||||||
|
'credibility_score': result.get("relevance_score", 0.5),
|
||||||
|
'published_at': result.get("published_date"),
|
||||||
|
'index': idx,
|
||||||
|
'source_type': source_type,
|
||||||
|
'content': result.get("content", ""),
|
||||||
|
'raw_content': result.get("raw_content"), # If include_raw_content was requested
|
||||||
|
'score': result.get("score", result.get("relevance_score", 0.5)),
|
||||||
|
'favicon': result.get("favicon")
|
||||||
|
})
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
def _determine_source_type(self, url):
|
||||||
|
"""Determine source type from URL."""
|
||||||
|
if not url:
|
||||||
|
return 'web'
|
||||||
|
|
||||||
|
url_lower = url.lower()
|
||||||
|
if 'arxiv.org' in url_lower or 'research' in url_lower or '.edu' in url_lower:
|
||||||
|
return 'academic'
|
||||||
|
elif any(news in url_lower for news in ['cnn.com', 'bbc.com', 'reuters.com', 'theguardian.com', 'nytimes.com']):
|
||||||
|
return 'news'
|
||||||
|
elif 'linkedin.com' in url_lower:
|
||||||
|
return 'expert'
|
||||||
|
elif '.gov' in url_lower:
|
||||||
|
return 'government'
|
||||||
|
else:
|
||||||
|
return 'web'
|
||||||
|
|
||||||
|
def _aggregate_content(self, results):
|
||||||
|
"""Aggregate content from Tavily results for LLM analysis."""
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
for idx, result in enumerate(results):
|
||||||
|
content = result.get("content", "")
|
||||||
|
if content:
|
||||||
|
content_parts.append(f"Source {idx + 1}: {content}")
|
||||||
|
|
||||||
|
return "\n\n".join(content_parts)
|
||||||
|
|
||||||
|
def track_tavily_usage(self, user_id: str, cost: float, search_depth: str):
|
||||||
|
"""Track Tavily API usage after successful call."""
|
||||||
|
from services.database import get_db
|
||||||
|
from services.subscription import PricingService
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
pricing_service = PricingService(db)
|
||||||
|
current_period = pricing_service.get_current_billing_period(user_id)
|
||||||
|
|
||||||
|
# Update tavily_calls and tavily_cost via SQL UPDATE
|
||||||
|
update_query = text("""
|
||||||
|
UPDATE usage_summaries
|
||||||
|
SET tavily_calls = COALESCE(tavily_calls, 0) + 1,
|
||||||
|
tavily_cost = COALESCE(tavily_cost, 0) + :cost,
|
||||||
|
total_calls = COALESCE(total_calls, 0) + 1,
|
||||||
|
total_cost = COALESCE(total_cost, 0) + :cost
|
||||||
|
WHERE user_id = :user_id AND billing_period = :period
|
||||||
|
""")
|
||||||
|
db.execute(update_query, {
|
||||||
|
'cost': cost,
|
||||||
|
'user_id': user_id,
|
||||||
|
'period': current_period
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"[Tavily] Tracked usage: user={user_id}, cost=${cost}, depth={search_depth}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tavily] Failed to track usage: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
265
backend/services/integrations/wix_oauth.py
Normal file
265
backend/services/integrations/wix_oauth.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""
|
||||||
|
Wix OAuth2 Service
|
||||||
|
Handles Wix OAuth2 authentication flow and token storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class WixOAuthService:
|
||||||
|
"""Manages Wix OAuth2 authentication flow and token storage."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "alwrity.db"):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""Initialize database tables for OAuth tokens."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS wix_oauth_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_type TEXT DEFAULT 'bearer',
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
expires_in INTEGER,
|
||||||
|
scope TEXT,
|
||||||
|
site_id TEXT,
|
||||||
|
member_id TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Wix OAuth database initialized.")
|
||||||
|
|
||||||
|
def store_tokens(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
access_token: str,
|
||||||
|
refresh_token: Optional[str] = None,
|
||||||
|
expires_in: Optional[int] = None,
|
||||||
|
token_type: str = 'bearer',
|
||||||
|
scope: Optional[str] = None,
|
||||||
|
site_id: Optional[str] = None,
|
||||||
|
member_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Store Wix OAuth tokens in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID (Clerk string)
|
||||||
|
access_token: Access token from Wix
|
||||||
|
refresh_token: Optional refresh token
|
||||||
|
expires_in: Optional expiration time in seconds
|
||||||
|
token_type: Token type (default: 'bearer')
|
||||||
|
scope: Optional OAuth scope
|
||||||
|
site_id: Optional Wix site ID
|
||||||
|
member_id: Optional Wix member ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if tokens were stored successfully
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
expires_at = None
|
||||||
|
if expires_in:
|
||||||
|
expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO wix_oauth_tokens
|
||||||
|
(user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id))
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Wix OAuth: Token inserted into database for user {user_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error storing Wix tokens for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all active Wix tokens for a user."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at
|
||||||
|
FROM wix_oauth_tokens
|
||||||
|
WHERE user_id = ? AND is_active = TRUE AND (expires_at IS NULL OR expires_at > datetime('now'))
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
tokens.append({
|
||||||
|
"id": row[0],
|
||||||
|
"access_token": row[1],
|
||||||
|
"refresh_token": row[2],
|
||||||
|
"token_type": row[3],
|
||||||
|
"expires_at": row[4],
|
||||||
|
"expires_in": row[5],
|
||||||
|
"scope": row[6],
|
||||||
|
"site_id": row[7],
|
||||||
|
"member_id": row[8],
|
||||||
|
"created_at": row[9]
|
||||||
|
})
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting Wix tokens for user {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed token status for a user including expired tokens."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all tokens (active and expired)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at, is_active
|
||||||
|
FROM wix_oauth_tokens
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
all_tokens = []
|
||||||
|
active_tokens = []
|
||||||
|
expired_tokens = []
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
token_data = {
|
||||||
|
"id": row[0],
|
||||||
|
"access_token": row[1],
|
||||||
|
"refresh_token": row[2],
|
||||||
|
"token_type": row[3],
|
||||||
|
"expires_at": row[4],
|
||||||
|
"expires_in": row[5],
|
||||||
|
"scope": row[6],
|
||||||
|
"site_id": row[7],
|
||||||
|
"member_id": row[8],
|
||||||
|
"created_at": row[9],
|
||||||
|
"is_active": bool(row[10])
|
||||||
|
}
|
||||||
|
all_tokens.append(token_data)
|
||||||
|
|
||||||
|
# Determine expiry using robust parsing and is_active flag
|
||||||
|
is_active_flag = bool(row[10])
|
||||||
|
not_expired = False
|
||||||
|
try:
|
||||||
|
expires_at_val = row[4]
|
||||||
|
if expires_at_val:
|
||||||
|
# First try Python parsing
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
|
||||||
|
not_expired = dt > datetime.now()
|
||||||
|
except Exception:
|
||||||
|
# Fallback to SQLite comparison
|
||||||
|
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
|
||||||
|
not_expired = cursor.fetchone()[0] == 1
|
||||||
|
else:
|
||||||
|
# No expiry stored => consider not expired
|
||||||
|
not_expired = True
|
||||||
|
except Exception:
|
||||||
|
not_expired = False
|
||||||
|
|
||||||
|
if is_active_flag and not_expired:
|
||||||
|
active_tokens.append(token_data)
|
||||||
|
else:
|
||||||
|
expired_tokens.append(token_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_tokens": len(all_tokens) > 0,
|
||||||
|
"has_active_tokens": len(active_tokens) > 0,
|
||||||
|
"has_expired_tokens": len(expired_tokens) > 0,
|
||||||
|
"active_tokens": active_tokens,
|
||||||
|
"expired_tokens": expired_tokens,
|
||||||
|
"total_tokens": len(all_tokens),
|
||||||
|
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting Wix token status for user {user_id}: {e}")
|
||||||
|
return {
|
||||||
|
"has_tokens": False,
|
||||||
|
"has_active_tokens": False,
|
||||||
|
"has_expired_tokens": False,
|
||||||
|
"active_tokens": [],
|
||||||
|
"expired_tokens": [],
|
||||||
|
"total_tokens": 0,
|
||||||
|
"last_token_date": None,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_tokens(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
access_token: str,
|
||||||
|
refresh_token: Optional[str] = None,
|
||||||
|
expires_in: Optional[int] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Update tokens for a user (e.g., after refresh)."""
|
||||||
|
try:
|
||||||
|
expires_at = None
|
||||||
|
if expires_in:
|
||||||
|
expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
if refresh_token:
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE wix_oauth_tokens
|
||||||
|
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
|
||||||
|
is_active = TRUE, updated_at = datetime('now')
|
||||||
|
WHERE user_id = ? AND refresh_token = ?
|
||||||
|
''', (access_token, refresh_token, expires_at, expires_in, user_id, refresh_token))
|
||||||
|
else:
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE wix_oauth_tokens
|
||||||
|
SET access_token = ?, expires_at = ?, expires_in = ?,
|
||||||
|
is_active = TRUE, updated_at = datetime('now')
|
||||||
|
WHERE user_id = ? AND id = (SELECT id FROM wix_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1)
|
||||||
|
''', (access_token, expires_at, expires_in, user_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Wix OAuth: Tokens updated for user {user_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating Wix tokens for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def revoke_token(self, user_id: str, token_id: int) -> bool:
|
||||||
|
"""Revoke a Wix OAuth token."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE wix_oauth_tokens
|
||||||
|
SET is_active = FALSE, updated_at = datetime('now')
|
||||||
|
WHERE user_id = ? AND id = ?
|
||||||
|
''', (user_id, token_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
logger.info(f"Wix token {token_id} revoked for user {user_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking Wix token: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
@@ -218,6 +218,87 @@ class WordPressOAuthService:
|
|||||||
logger.error(f"Error getting WordPress tokens for user {user_id}: {e}")
|
logger.error(f"Error getting WordPress tokens for user {user_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed token status for a user including expired tokens."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all tokens (active and expired)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, access_token, refresh_token, token_type, expires_at, scope, blog_id, blog_url, created_at, is_active
|
||||||
|
FROM wordpress_oauth_tokens
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
all_tokens = []
|
||||||
|
active_tokens = []
|
||||||
|
expired_tokens = []
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
token_data = {
|
||||||
|
"id": row[0],
|
||||||
|
"access_token": row[1],
|
||||||
|
"refresh_token": row[2],
|
||||||
|
"token_type": row[3],
|
||||||
|
"expires_at": row[4],
|
||||||
|
"scope": row[5],
|
||||||
|
"blog_id": row[6],
|
||||||
|
"blog_url": row[7],
|
||||||
|
"created_at": row[8],
|
||||||
|
"is_active": bool(row[9])
|
||||||
|
}
|
||||||
|
all_tokens.append(token_data)
|
||||||
|
|
||||||
|
# Determine expiry using robust parsing and is_active flag
|
||||||
|
is_active_flag = bool(row[9])
|
||||||
|
not_expired = False
|
||||||
|
try:
|
||||||
|
expires_at_val = row[4]
|
||||||
|
if expires_at_val:
|
||||||
|
# First try Python parsing
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
|
||||||
|
not_expired = dt > datetime.now()
|
||||||
|
except Exception:
|
||||||
|
# Fallback to SQLite comparison
|
||||||
|
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
|
||||||
|
not_expired = cursor.fetchone()[0] == 1
|
||||||
|
else:
|
||||||
|
# No expiry stored => consider not expired
|
||||||
|
not_expired = True
|
||||||
|
except Exception:
|
||||||
|
not_expired = False
|
||||||
|
|
||||||
|
if is_active_flag and not_expired:
|
||||||
|
active_tokens.append(token_data)
|
||||||
|
else:
|
||||||
|
expired_tokens.append(token_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_tokens": len(all_tokens) > 0,
|
||||||
|
"has_active_tokens": len(active_tokens) > 0,
|
||||||
|
"has_expired_tokens": len(expired_tokens) > 0,
|
||||||
|
"active_tokens": active_tokens,
|
||||||
|
"expired_tokens": expired_tokens,
|
||||||
|
"total_tokens": len(all_tokens),
|
||||||
|
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting WordPress token status for user {user_id}: {e}")
|
||||||
|
return {
|
||||||
|
"has_tokens": False,
|
||||||
|
"has_active_tokens": False,
|
||||||
|
"has_expired_tokens": False,
|
||||||
|
"active_tokens": [],
|
||||||
|
"expired_tokens": [],
|
||||||
|
"total_tokens": 0,
|
||||||
|
"last_token_date": None,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
def test_token(self, access_token: str) -> bool:
|
def test_token(self, access_token: str) -> bool:
|
||||||
"""Test if a WordPress access token is valid."""
|
"""Test if a WordPress access token is valid."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
|
|||||||
from services.gsc_service import GSCService
|
from services.gsc_service import GSCService
|
||||||
from services.integrations.bing_oauth import BingOAuthService
|
from services.integrations.bing_oauth import BingOAuthService
|
||||||
from services.integrations.wordpress_oauth import WordPressOAuthService
|
from services.integrations.wordpress_oauth import WordPressOAuthService
|
||||||
|
from services.integrations.wix_oauth import WixOAuthService
|
||||||
# Note: Wix tokens are stored in frontend sessionStorage, not backend database
|
|
||||||
# So we cannot check for Wix connections from the backend yet
|
|
||||||
|
|
||||||
|
|
||||||
def get_connected_platforms(user_id: str) -> List[str]:
|
def get_connected_platforms(user_id: str) -> List[str]:
|
||||||
@@ -29,7 +27,7 @@ def get_connected_platforms(user_id: str) -> List[str]:
|
|||||||
- GSC: gsc_credentials table
|
- GSC: gsc_credentials table
|
||||||
- Bing: bing_oauth_tokens table
|
- Bing: bing_oauth_tokens table
|
||||||
- WordPress: wordpress_oauth_tokens table
|
- WordPress: wordpress_oauth_tokens table
|
||||||
- Wix: Not checked (tokens in frontend sessionStorage)
|
- Wix: wix_oauth_tokens table
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User ID (Clerk string)
|
user_id: User ID (Clerk string)
|
||||||
@@ -39,57 +37,84 @@ def get_connected_platforms(user_id: str) -> List[str]:
|
|||||||
"""
|
"""
|
||||||
connected = []
|
connected = []
|
||||||
|
|
||||||
logger.warning(f"[OAuth Monitoring] Checking connected platforms for user: {user_id}")
|
# Use DEBUG level for routine checks (called frequently by dashboard)
|
||||||
|
logger.debug(f"[OAuth Monitoring] Checking connected platforms for user: {user_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check GSC - use absolute database path
|
# Check GSC - use absolute database path
|
||||||
db_path = os.path.abspath("alwrity.db")
|
db_path = os.path.abspath("alwrity.db")
|
||||||
logger.warning(f"[OAuth Monitoring] Checking GSC with db_path: {db_path}")
|
|
||||||
gsc_service = GSCService(db_path=db_path)
|
gsc_service = GSCService(db_path=db_path)
|
||||||
gsc_credentials = gsc_service.load_user_credentials(user_id)
|
gsc_credentials = gsc_service.load_user_credentials(user_id)
|
||||||
if gsc_credentials:
|
if gsc_credentials:
|
||||||
connected.append('gsc')
|
connected.append('gsc')
|
||||||
logger.warning(f"[OAuth Monitoring] ✅ GSC connected for user {user_id}")
|
logger.debug(f"[OAuth Monitoring] ✅ GSC connected for user {user_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[OAuth Monitoring] ❌ GSC not connected for user {user_id} (no credentials found)")
|
logger.debug(f"[OAuth Monitoring] ❌ GSC not connected for user {user_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[OAuth Monitoring] ⚠️ GSC check failed for user {user_id}: {e}", exc_info=True)
|
logger.warning(f"[OAuth Monitoring] ⚠️ GSC check failed for user {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check Bing - use absolute database path
|
# Check Bing - use absolute database path
|
||||||
db_path = os.path.abspath("alwrity.db")
|
db_path = os.path.abspath("alwrity.db")
|
||||||
logger.warning(f"[OAuth Monitoring] Checking Bing with db_path: {db_path}")
|
|
||||||
bing_service = BingOAuthService(db_path=db_path)
|
bing_service = BingOAuthService(db_path=db_path)
|
||||||
token_status = bing_service.get_user_token_status(user_id)
|
token_status = bing_service.get_user_token_status(user_id)
|
||||||
has_tokens = token_status.get('has_active_tokens', False)
|
has_active_tokens = token_status.get('has_active_tokens', False)
|
||||||
logger.warning(f"[OAuth Monitoring] Bing token_status keys: {list(token_status.keys())}, has_active_tokens: {has_tokens}")
|
has_expired_tokens = token_status.get('has_expired_tokens', False)
|
||||||
if has_tokens:
|
expired_tokens = token_status.get('expired_tokens', [])
|
||||||
|
|
||||||
|
# Check if expired tokens have refresh tokens (can be refreshed)
|
||||||
|
has_refreshable_tokens = any(token.get('refresh_token') for token in expired_tokens)
|
||||||
|
|
||||||
|
# Consider connected if user has active tokens OR expired tokens with refresh tokens
|
||||||
|
if has_active_tokens or (has_expired_tokens and has_refreshable_tokens):
|
||||||
connected.append('bing')
|
connected.append('bing')
|
||||||
logger.warning(f"[OAuth Monitoring] ✅ Bing connected for user {user_id}")
|
logger.debug(f"[OAuth Monitoring] ✅ Bing connected for user {user_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[OAuth Monitoring] ❌ Bing not connected for user {user_id} (no active tokens)")
|
logger.debug(f"[OAuth Monitoring] ❌ Bing not connected for user {user_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[OAuth Monitoring] ⚠️ Bing check failed for user {user_id}: {e}", exc_info=True)
|
logger.warning(f"[OAuth Monitoring] ⚠️ Bing check failed for user {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check WordPress - use absolute database path
|
# Check WordPress - use absolute database path
|
||||||
db_path = os.path.abspath("alwrity.db")
|
db_path = os.path.abspath("alwrity.db")
|
||||||
logger.warning(f"[OAuth Monitoring] Checking WordPress with db_path: {db_path}")
|
|
||||||
wordpress_service = WordPressOAuthService(db_path=db_path)
|
wordpress_service = WordPressOAuthService(db_path=db_path)
|
||||||
tokens = wordpress_service.get_user_tokens(user_id)
|
token_status = wordpress_service.get_user_token_status(user_id)
|
||||||
logger.warning(f"[OAuth Monitoring] WordPress tokens found: {len(tokens) if tokens else 0}")
|
has_active_tokens = token_status.get('has_active_tokens', False)
|
||||||
if tokens and len(tokens) > 0:
|
has_tokens = token_status.get('has_tokens', False)
|
||||||
|
|
||||||
|
# Consider connected if user has any tokens (WordPress tokens may not have refresh tokens)
|
||||||
|
# If tokens exist, user was connected even if expired (may need re-auth)
|
||||||
|
if has_tokens:
|
||||||
connected.append('wordpress')
|
connected.append('wordpress')
|
||||||
logger.warning(f"[OAuth Monitoring] ✅ WordPress connected for user {user_id} ({len(tokens)} token(s))")
|
logger.debug(f"[OAuth Monitoring] ✅ WordPress connected for user {user_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[OAuth Monitoring] ❌ WordPress not connected for user {user_id} (no tokens found)")
|
logger.debug(f"[OAuth Monitoring] ❌ WordPress not connected for user {user_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[OAuth Monitoring] ⚠️ WordPress check failed for user {user_id}: {e}", exc_info=True)
|
logger.warning(f"[OAuth Monitoring] ⚠️ WordPress check failed for user {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Wix: Not checked (tokens in frontend sessionStorage)
|
try:
|
||||||
# TODO: Once backend storage is implemented, check wix_tokens table
|
# Check Wix - use absolute database path
|
||||||
|
db_path = os.path.abspath("alwrity.db")
|
||||||
|
wix_service = WixOAuthService(db_path=db_path)
|
||||||
|
token_status = wix_service.get_user_token_status(user_id)
|
||||||
|
has_active_tokens = token_status.get('has_active_tokens', False)
|
||||||
|
has_expired_tokens = token_status.get('has_expired_tokens', False)
|
||||||
|
expired_tokens = token_status.get('expired_tokens', [])
|
||||||
|
|
||||||
|
# Check if expired tokens have refresh tokens (can be refreshed)
|
||||||
|
has_refreshable_tokens = any(token.get('refresh_token') for token in expired_tokens)
|
||||||
|
|
||||||
|
# Consider connected if user has active tokens OR expired tokens with refresh tokens
|
||||||
|
if has_active_tokens or (has_expired_tokens and has_refreshable_tokens):
|
||||||
|
connected.append('wix')
|
||||||
|
logger.debug(f"[OAuth Monitoring] ✅ Wix connected for user {user_id}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[OAuth Monitoring] ❌ Wix not connected for user {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[OAuth Monitoring] ⚠️ Wix check failed for user {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
logger.warning(f"[OAuth Monitoring] Connected platforms for user {user_id}: {connected}")
|
# Don't log here - let the caller log a formatted summary if needed
|
||||||
|
# This function is called frequently and should be silent
|
||||||
return connected
|
return connected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -265,7 +265,27 @@ class OnboardingProgress:
|
|||||||
# Log database save confirmation
|
# Log database save confirmation
|
||||||
logger.info(f"✅ DATABASE: API key for {provider} saved to database for user {self.user_id}")
|
logger.info(f"✅ DATABASE: API key for {provider} saved to database for user {self.user_id}")
|
||||||
elif step.step_number == 2: # Website Analysis
|
elif step.step_number == 2: # Website Analysis
|
||||||
self.db_service.save_website_analysis(self.user_id, step.data, db)
|
# Transform frontend data structure to match database schema
|
||||||
|
# Frontend sends: { website: "url", analysis: {...} }
|
||||||
|
# Database expects: { website_url: "url", ...analysis (flattened) }
|
||||||
|
analysis_for_db = {}
|
||||||
|
if step.data:
|
||||||
|
# Extract website_url from 'website' or 'website_url' field
|
||||||
|
website_url = step.data.get('website') or step.data.get('website_url')
|
||||||
|
if website_url:
|
||||||
|
analysis_for_db['website_url'] = website_url
|
||||||
|
# Flatten nested 'analysis' object if it exists
|
||||||
|
if 'analysis' in step.data and isinstance(step.data['analysis'], dict):
|
||||||
|
analysis_for_db.update(step.data['analysis'])
|
||||||
|
# Also include any other top-level fields (except 'website' and 'analysis')
|
||||||
|
for key, value in step.data.items():
|
||||||
|
if key not in ['website', 'website_url', 'analysis']:
|
||||||
|
analysis_for_db[key] = value
|
||||||
|
# Ensure status is set
|
||||||
|
if 'status' not in analysis_for_db:
|
||||||
|
analysis_for_db['status'] = 'completed'
|
||||||
|
|
||||||
|
self.db_service.save_website_analysis(self.user_id, analysis_for_db, db)
|
||||||
logger.info(f"✅ DATABASE: Website analysis saved to database for user {self.user_id}")
|
logger.info(f"✅ DATABASE: Website analysis saved to database for user {self.user_id}")
|
||||||
elif step.step_number == 3: # Research Preferences
|
elif step.step_number == 3: # Research Preferences
|
||||||
self.db_service.save_research_preferences(self.user_id, step.data, db)
|
self.db_service.save_research_preferences(self.user_id, step.data, db)
|
||||||
|
|||||||
@@ -336,8 +336,13 @@ class OnboardingDatabaseService:
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Update existing
|
# Update existing - only update website_url if normalized value is not empty
|
||||||
existing.website_url = normalized.get('website_url', existing.website_url)
|
# This prevents overwriting a valid URL with an empty string when step.data
|
||||||
|
# doesn't include the website field
|
||||||
|
normalized_url = normalized.get('website_url', '').strip() if normalized.get('website_url') else ''
|
||||||
|
if normalized_url:
|
||||||
|
existing.website_url = normalized_url
|
||||||
|
# If normalized_url is empty, keep existing.website_url unchanged
|
||||||
existing.writing_style = normalized.get('writing_style')
|
existing.writing_style = normalized.get('writing_style')
|
||||||
existing.content_characteristics = normalized.get('content_characteristics')
|
existing.content_characteristics = normalized.get('content_characteristics')
|
||||||
existing.target_audience = normalized.get('target_audience')
|
existing.target_audience = normalized.get('target_audience')
|
||||||
@@ -522,6 +527,52 @@ class OnboardingDatabaseService:
|
|||||||
logger.error(f"Error getting research preferences: {e}")
|
logger.error(f"Error getting research preferences: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_competitor_analysis(self, user_id: str, db: Session = None) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""Get competitor analysis data for user from onboarding."""
|
||||||
|
session_db = db or self.db
|
||||||
|
if not session_db:
|
||||||
|
raise ValueError("Database session required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models.onboarding import CompetitorAnalysis
|
||||||
|
|
||||||
|
session = self.get_session_by_user(user_id, session_db)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Query CompetitorAnalysis table
|
||||||
|
competitor_records = session_db.query(CompetitorAnalysis).filter(
|
||||||
|
CompetitorAnalysis.session_id == session.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not competitor_records:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert to list of dicts
|
||||||
|
competitors = []
|
||||||
|
for record in competitor_records:
|
||||||
|
analysis_data = record.analysis_data or {}
|
||||||
|
competitors.append({
|
||||||
|
"url": record.competitor_url,
|
||||||
|
"domain": record.competitor_domain or record.competitor_url,
|
||||||
|
"title": analysis_data.get("title", record.competitor_domain or ""),
|
||||||
|
"summary": analysis_data.get("summary", ""),
|
||||||
|
"relevance_score": analysis_data.get("relevance_score", 0.5),
|
||||||
|
"highlights": analysis_data.get("highlights", []),
|
||||||
|
"favicon": analysis_data.get("favicon"),
|
||||||
|
"image": analysis_data.get("image"),
|
||||||
|
"published_date": analysis_data.get("published_date"),
|
||||||
|
"author": analysis_data.get("author"),
|
||||||
|
"competitive_insights": analysis_data.get("competitive_analysis", {}),
|
||||||
|
"content_insights": analysis_data.get("content_insights", {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return competitors
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Error getting competitor analysis: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_persona_data(self, user_id: str, db: Session = None) -> Optional[Dict[str, Any]]:
|
def get_persona_data(self, user_id: str, db: Session = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get persona data for user."""
|
"""Get persona data for user."""
|
||||||
session_db = db or self.db
|
session_db = db or self.db
|
||||||
|
|||||||
136
backend/services/platform_insights_monitoring_service.py
Normal file
136
backend/services/platform_insights_monitoring_service.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Platform Insights Monitoring Service
|
||||||
|
Creates and manages platform insights (GSC/Bing) fetch tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
logger = get_service_logger("platform_insights_monitoring")
|
||||||
|
|
||||||
|
|
||||||
|
def create_platform_insights_task(
|
||||||
|
user_id: str,
|
||||||
|
platform: str, # 'gsc' or 'bing'
|
||||||
|
site_url: Optional[str] = None,
|
||||||
|
db: Session = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a platform insights fetch task for a user.
|
||||||
|
|
||||||
|
This should be called when user connects GSC or Bing in Step 5.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Clerk user ID (string)
|
||||||
|
platform: Platform name ('gsc' or 'bing')
|
||||||
|
site_url: Optional site URL (for GSC/Bing specific site)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and task details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"[Platform Insights] Creating {platform} insights task for user: {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if task already exists
|
||||||
|
existing = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.user_id == user_id,
|
||||||
|
PlatformInsightsTask.platform == platform
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"[Platform Insights] Task already exists for user {user_id}, platform {platform}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'task_id': existing.id,
|
||||||
|
'message': 'Task already exists',
|
||||||
|
'existing': True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate next check (7 days from now, weekly schedule)
|
||||||
|
next_check = datetime.utcnow() + timedelta(days=7)
|
||||||
|
|
||||||
|
# Create new task
|
||||||
|
task = PlatformInsightsTask(
|
||||||
|
user_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
site_url=site_url,
|
||||||
|
status='active',
|
||||||
|
next_check=next_check,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Platform Insights] Created {platform} insights task {task.id} for user {user_id}, "
|
||||||
|
f"next_check: {next_check}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'task_id': task.id,
|
||||||
|
'platform': platform,
|
||||||
|
'next_check': next_check.isoformat(),
|
||||||
|
'message': f'{platform.upper()} insights task created successfully'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error creating {platform} insights task for user {user_id}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_insights_tasks(
|
||||||
|
user_id: str,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
db: Session = None
|
||||||
|
) -> List[PlatformInsightsTask]:
|
||||||
|
"""
|
||||||
|
Get all platform insights tasks for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Clerk user ID (string)
|
||||||
|
platform: Optional platform filter ('gsc' or 'bing')
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PlatformInsightsTask instances
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.user_id == user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform:
|
||||||
|
query = query.filter(PlatformInsightsTask.platform == platform)
|
||||||
|
|
||||||
|
tasks = query.all()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights] Found {len(tasks)} insights tasks for user {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting insights tasks for user {user_id}: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ Last Updated: January 2025
|
|||||||
|
|
||||||
from .google_search_service import GoogleSearchService
|
from .google_search_service import GoogleSearchService
|
||||||
from .exa_service import ExaService
|
from .exa_service import ExaService
|
||||||
|
from .tavily_service import TavilyService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GoogleSearchService",
|
"GoogleSearchService",
|
||||||
"ExaService"
|
"ExaService",
|
||||||
|
"TavilyService"
|
||||||
]
|
]
|
||||||
|
|||||||
425
backend/services/research/tavily_service.py
Normal file
425
backend/services/research/tavily_service.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"""
|
||||||
|
Tavily API Service for ALwrity
|
||||||
|
|
||||||
|
This service provides web search and research capabilities using the Tavily API,
|
||||||
|
which offers AI-powered search with real-time information retrieval.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Web search with AI-powered results
|
||||||
|
- Content extraction and summarization
|
||||||
|
- Real-time information retrieval
|
||||||
|
- Topic-based search (general, news, finance)
|
||||||
|
- Advanced search depth options
|
||||||
|
- Cost-effective API usage with caching
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- aiohttp (for async HTTP requests)
|
||||||
|
- os (for environment variables)
|
||||||
|
- logging (for debugging)
|
||||||
|
|
||||||
|
Author: ALwrity Team
|
||||||
|
Version: 1.0
|
||||||
|
Last Updated: January 2025
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
from typing import Dict, List, Optional, Any, Union
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from loguru import logger
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
class TavilyService:
|
||||||
|
"""
|
||||||
|
Service for web search and research using the Tavily API.
|
||||||
|
|
||||||
|
This service provides AI-powered search capabilities to find relevant
|
||||||
|
content and information for research purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Tavily Service with API credentials."""
|
||||||
|
self.api_key = os.getenv("TAVILY_API_KEY")
|
||||||
|
self.base_url = "https://api.tavily.com"
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
# Don't assume key is available at import time in production.
|
||||||
|
# Keys may be injected per-request via middleware, so defer init.
|
||||||
|
self._try_initialize()
|
||||||
|
|
||||||
|
def _try_initialize(self) -> None:
|
||||||
|
"""Attempt to (re)initialize the Tavily service from current environment."""
|
||||||
|
if self.enabled and self.api_key:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.api_key = os.getenv("TAVILY_API_KEY")
|
||||||
|
if not self.api_key:
|
||||||
|
# Leave disabled; caller may try again after middleware injection
|
||||||
|
logger.warning("TAVILY_API_KEY not configured; Tavily service will be disabled")
|
||||||
|
self.enabled = False
|
||||||
|
return
|
||||||
|
self.enabled = True
|
||||||
|
logger.info("Tavily Service initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Tavily service: {e}")
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
topic: str = "general",
|
||||||
|
search_depth: str = "basic",
|
||||||
|
max_results: int = 10,
|
||||||
|
include_domains: Optional[List[str]] = None,
|
||||||
|
exclude_domains: Optional[List[str]] = None,
|
||||||
|
include_answer: Union[bool, str] = False,
|
||||||
|
include_raw_content: Union[bool, str] = False,
|
||||||
|
include_images: bool = False,
|
||||||
|
include_image_descriptions: bool = False,
|
||||||
|
include_favicon: bool = False,
|
||||||
|
time_range: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
country: Optional[str] = None,
|
||||||
|
chunks_per_source: int = 3,
|
||||||
|
auto_parameters: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute a search query using Tavily API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query to execute
|
||||||
|
topic: Category of search (general, news, finance)
|
||||||
|
search_depth: Depth of search (basic, advanced) - basic costs 1 credit, advanced costs 2
|
||||||
|
max_results: Maximum number of results to return (0-20)
|
||||||
|
include_domains: List of domains to specifically include
|
||||||
|
exclude_domains: List of domains to specifically exclude
|
||||||
|
include_answer: Include LLM-generated answer (basic/advanced/true/false)
|
||||||
|
include_raw_content: Include raw HTML content (markdown/text/true/false)
|
||||||
|
include_images: Include image search results
|
||||||
|
include_image_descriptions: Include image descriptions
|
||||||
|
include_favicon: Include favicon URLs
|
||||||
|
time_range: Time range filter (day, week, month, year, d, w, m, y)
|
||||||
|
start_date: Start date filter (YYYY-MM-DD)
|
||||||
|
end_date: End date filter (YYYY-MM-DD)
|
||||||
|
country: Country filter (boost results from specific country)
|
||||||
|
chunks_per_source: Maximum chunks per source (1-3, only for advanced search)
|
||||||
|
auto_parameters: Auto-configure parameters based on query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing search results
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure we pick up any per-request injected key
|
||||||
|
self._try_initialize()
|
||||||
|
if not self.enabled:
|
||||||
|
raise ValueError("Tavily Service is not enabled - API key missing")
|
||||||
|
|
||||||
|
logger.info(f"Starting Tavily search for: {query}")
|
||||||
|
|
||||||
|
# Build request payload
|
||||||
|
payload = {
|
||||||
|
"api_key": self.api_key,
|
||||||
|
"query": query,
|
||||||
|
"topic": topic,
|
||||||
|
"search_depth": search_depth,
|
||||||
|
"max_results": min(max_results, 20), # Tavily limit
|
||||||
|
"include_favicon": include_favicon
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if include_domains:
|
||||||
|
payload["include_domains"] = include_domains[:300] # Tavily limit
|
||||||
|
|
||||||
|
if exclude_domains:
|
||||||
|
payload["exclude_domains"] = exclude_domains[:150] # Tavily limit
|
||||||
|
|
||||||
|
if include_answer:
|
||||||
|
payload["include_answer"] = include_answer
|
||||||
|
|
||||||
|
if include_raw_content:
|
||||||
|
payload["include_raw_content"] = include_raw_content
|
||||||
|
|
||||||
|
if include_images:
|
||||||
|
payload["include_images"] = include_images
|
||||||
|
if include_image_descriptions:
|
||||||
|
payload["include_image_descriptions"] = include_image_descriptions
|
||||||
|
|
||||||
|
if time_range:
|
||||||
|
payload["time_range"] = time_range
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
payload["start_date"] = start_date
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
payload["end_date"] = end_date
|
||||||
|
|
||||||
|
if country and topic == "general":
|
||||||
|
payload["country"] = country
|
||||||
|
|
||||||
|
if search_depth == "advanced" and 1 <= chunks_per_source <= 3:
|
||||||
|
payload["chunks_per_source"] = chunks_per_source
|
||||||
|
|
||||||
|
if auto_parameters:
|
||||||
|
payload["auto_parameters"] = True
|
||||||
|
|
||||||
|
# Make API request
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{self.base_url}/search",
|
||||||
|
json=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=60)
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
result = await response.json()
|
||||||
|
logger.info(f"Tavily search completed successfully. Found {len(result.get('results', []))} results.")
|
||||||
|
|
||||||
|
# Process and structure results
|
||||||
|
processed_results = self._process_search_results(result, query)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"query": result.get("query", query),
|
||||||
|
"answer": result.get("answer"), # If include_answer was requested
|
||||||
|
"results": processed_results,
|
||||||
|
"images": result.get("images", []),
|
||||||
|
"response_time": result.get("response_time"),
|
||||||
|
"request_id": result.get("request_id"),
|
||||||
|
"auto_parameters": result.get("auto_parameters"),
|
||||||
|
"total_results": len(processed_results),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
logger.error(f"Tavily API error: {response.status} - {error_text}")
|
||||||
|
raise RuntimeError(f"Tavily API error: {response.status} - {error_text}")
|
||||||
|
|
||||||
|
except aiohttp.ClientTimeout:
|
||||||
|
logger.error("Tavily API request timed out")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Request timed out",
|
||||||
|
"details": "The search request took too long to complete"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in Tavily search: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"details": "An unexpected error occurred during search"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _process_search_results(self, api_response: Dict[str, Any], query: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Process and structure Tavily API response into standardized format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_response: Raw response from Tavily API
|
||||||
|
query: Original search query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processed search results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
raw_results = api_response.get("results", [])
|
||||||
|
|
||||||
|
for result in raw_results:
|
||||||
|
try:
|
||||||
|
# Extract domain from URL
|
||||||
|
url = result.get("url", "")
|
||||||
|
domain = urlparse(url).netloc if url else ""
|
||||||
|
|
||||||
|
# Calculate relevance score (Tavily provides score field)
|
||||||
|
relevance_score = result.get("score", 0.5)
|
||||||
|
|
||||||
|
processed_result = {
|
||||||
|
"url": url,
|
||||||
|
"domain": domain,
|
||||||
|
"title": result.get("title", ""),
|
||||||
|
"content": result.get("content", ""),
|
||||||
|
"raw_content": result.get("raw_content"), # If include_raw_content was requested
|
||||||
|
"score": relevance_score,
|
||||||
|
"relevance_score": relevance_score, # Alias for compatibility
|
||||||
|
"favicon": result.get("favicon"),
|
||||||
|
"published_date": result.get("published_date"),
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append(processed_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error processing Tavily result: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by relevance score (highest first)
|
||||||
|
results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def search_industry_trends(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
industry: str,
|
||||||
|
max_results: int = 10,
|
||||||
|
search_depth: str = "basic"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Search for current industry trends and insights.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The specific topic to research
|
||||||
|
industry: The industry context for the search
|
||||||
|
max_results: Maximum number of search results to return
|
||||||
|
search_depth: Depth of search (basic or advanced)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing search results with industry context
|
||||||
|
"""
|
||||||
|
# Build industry-specific query
|
||||||
|
search_query = f"{topic} {industry} trends insights"
|
||||||
|
|
||||||
|
# Use news topic for current trends
|
||||||
|
return await self.search(
|
||||||
|
query=search_query,
|
||||||
|
topic="news" if search_depth == "basic" else "general",
|
||||||
|
search_depth=search_depth,
|
||||||
|
max_results=max_results,
|
||||||
|
include_answer="basic",
|
||||||
|
include_favicon=True,
|
||||||
|
time_range="month" # Last month for current trends
|
||||||
|
)
|
||||||
|
|
||||||
|
async def discover_competitors(
|
||||||
|
self,
|
||||||
|
user_url: str,
|
||||||
|
num_results: int = 10,
|
||||||
|
include_domains: Optional[List[str]] = None,
|
||||||
|
exclude_domains: Optional[List[str]] = None,
|
||||||
|
industry_context: Optional[str] = None,
|
||||||
|
website_analysis_data: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Discover competitors for a given website using Tavily search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_url: The website URL to find competitors for
|
||||||
|
num_results: Number of competitor results to return
|
||||||
|
include_domains: List of domains to include in search
|
||||||
|
exclude_domains: List of domains to exclude from search
|
||||||
|
industry_context: Industry context for better competitor discovery
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing competitor analysis results
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure we pick up any per-request injected key
|
||||||
|
self._try_initialize()
|
||||||
|
if not self.enabled:
|
||||||
|
raise ValueError("Tavily Service is not enabled - API key missing")
|
||||||
|
|
||||||
|
logger.info(f"Starting competitor discovery for: {user_url}")
|
||||||
|
|
||||||
|
# Extract user domain for exclusion
|
||||||
|
user_domain = urlparse(user_url).netloc
|
||||||
|
exclude_domains_list = exclude_domains or []
|
||||||
|
exclude_domains_list.append(user_domain)
|
||||||
|
|
||||||
|
# Build search query
|
||||||
|
query_parts = ["similar websites", "competitors"]
|
||||||
|
if industry_context:
|
||||||
|
query_parts.append(f"in {industry_context}")
|
||||||
|
|
||||||
|
# Extract insights from website analysis if available
|
||||||
|
if website_analysis_data:
|
||||||
|
analysis = website_analysis_data.get('analysis', {})
|
||||||
|
if 'target_audience' in analysis:
|
||||||
|
audience = analysis['target_audience']
|
||||||
|
if isinstance(audience, dict) and 'primary_audience' in audience:
|
||||||
|
query_parts.append(audience['primary_audience'])
|
||||||
|
|
||||||
|
search_query = " ".join(query_parts)
|
||||||
|
|
||||||
|
# Perform search
|
||||||
|
search_result = await self.search(
|
||||||
|
query=search_query,
|
||||||
|
topic="general",
|
||||||
|
search_depth="advanced", # Use advanced for better competitor discovery
|
||||||
|
max_results=num_results,
|
||||||
|
include_domains=include_domains,
|
||||||
|
exclude_domains=exclude_domains_list,
|
||||||
|
include_favicon=True,
|
||||||
|
chunks_per_source=3
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_result.get("success"):
|
||||||
|
return search_result
|
||||||
|
|
||||||
|
# Process results into competitor format
|
||||||
|
competitors = []
|
||||||
|
for result in search_result.get("results", []):
|
||||||
|
competitor_data = {
|
||||||
|
"url": result.get("url"),
|
||||||
|
"domain": result.get("domain"),
|
||||||
|
"title": result.get("title"),
|
||||||
|
"summary": result.get("content", ""),
|
||||||
|
"relevance_score": result.get("relevance_score", 0.5),
|
||||||
|
"favicon": result.get("favicon"),
|
||||||
|
"published_date": result.get("published_date"),
|
||||||
|
"highlights": self._extract_highlights(result.get("content", "")),
|
||||||
|
"competitive_insights": self._extract_competitive_insights(result),
|
||||||
|
"content_insights": self._analyze_content_quality(result)
|
||||||
|
}
|
||||||
|
competitors.append(competitor_data)
|
||||||
|
|
||||||
|
logger.info(f"Successfully discovered {len(competitors)} competitors for {user_url}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_url": user_url,
|
||||||
|
"competitors": competitors,
|
||||||
|
"total_competitors": len(competitors),
|
||||||
|
"analysis_timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"industry_context": industry_context,
|
||||||
|
"request_id": search_result.get("request_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in competitor discovery: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"details": "An unexpected error occurred during competitor discovery"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_highlights(self, content: str, num_sentences: int = 3) -> List[str]:
|
||||||
|
"""Extract key highlights from content."""
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Simple sentence extraction (can be enhanced with NLP)
|
||||||
|
sentences = [s.strip() for s in content.split('.') if s.strip()]
|
||||||
|
return sentences[:num_sentences]
|
||||||
|
|
||||||
|
def _extract_competitive_insights(self, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Extract competitive insights from search result."""
|
||||||
|
content = result.get("content", "")
|
||||||
|
title = result.get("title", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"business_model": "unknown",
|
||||||
|
"target_audience": "unknown",
|
||||||
|
"key_differentiators": []
|
||||||
|
}
|
||||||
|
|
||||||
|
def _analyze_content_quality(self, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Analyze content quality metrics."""
|
||||||
|
content = result.get("content", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content_focus": "general",
|
||||||
|
"content_quality": "medium",
|
||||||
|
"publishing_frequency": "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@ Task Scheduler Package
|
|||||||
Modular, pluggable scheduler for ALwrity tasks.
|
Modular, pluggable scheduler for ALwrity tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .core.scheduler import TaskScheduler
|
from .core.scheduler import TaskScheduler
|
||||||
from .core.executor_interface import TaskExecutor, TaskExecutionResult
|
from .core.executor_interface import TaskExecutor, TaskExecutionResult
|
||||||
from .core.exception_handler import (
|
from .core.exception_handler import (
|
||||||
@@ -11,8 +13,13 @@ from .core.exception_handler import (
|
|||||||
)
|
)
|
||||||
from .executors.monitoring_task_executor import MonitoringTaskExecutor
|
from .executors.monitoring_task_executor import MonitoringTaskExecutor
|
||||||
from .executors.oauth_token_monitoring_executor import OAuthTokenMonitoringExecutor
|
from .executors.oauth_token_monitoring_executor import OAuthTokenMonitoringExecutor
|
||||||
|
from .executors.website_analysis_executor import WebsiteAnalysisExecutor
|
||||||
|
from .executors.gsc_insights_executor import GSCInsightsExecutor
|
||||||
|
from .executors.bing_insights_executor import BingInsightsExecutor
|
||||||
from .utils.task_loader import load_due_monitoring_tasks
|
from .utils.task_loader import load_due_monitoring_tasks
|
||||||
from .utils.oauth_token_task_loader import load_due_oauth_token_monitoring_tasks
|
from .utils.oauth_token_task_loader import load_due_oauth_token_monitoring_tasks
|
||||||
|
from .utils.website_analysis_task_loader import load_due_website_analysis_tasks
|
||||||
|
from .utils.platform_insights_task_loader import load_due_platform_insights_tasks
|
||||||
|
|
||||||
# Global scheduler instance (initialized on first access)
|
# Global scheduler instance (initialized on first access)
|
||||||
_scheduler_instance: TaskScheduler = None
|
_scheduler_instance: TaskScheduler = None
|
||||||
@@ -47,6 +54,37 @@ def get_scheduler() -> TaskScheduler:
|
|||||||
oauth_token_executor,
|
oauth_token_executor,
|
||||||
load_due_oauth_token_monitoring_tasks
|
load_due_oauth_token_monitoring_tasks
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register website analysis executor
|
||||||
|
website_analysis_executor = WebsiteAnalysisExecutor()
|
||||||
|
_scheduler_instance.register_executor(
|
||||||
|
'website_analysis',
|
||||||
|
website_analysis_executor,
|
||||||
|
load_due_website_analysis_tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register platform insights executors
|
||||||
|
# GSC insights executor
|
||||||
|
def load_due_gsc_insights_tasks(db: Session, user_id=None):
|
||||||
|
return load_due_platform_insights_tasks(db, user_id, platform='gsc')
|
||||||
|
|
||||||
|
gsc_insights_executor = GSCInsightsExecutor()
|
||||||
|
_scheduler_instance.register_executor(
|
||||||
|
'gsc_insights',
|
||||||
|
gsc_insights_executor,
|
||||||
|
load_due_gsc_insights_tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bing insights executor
|
||||||
|
def load_due_bing_insights_tasks(db: Session, user_id=None):
|
||||||
|
return load_due_platform_insights_tasks(db, user_id, platform='bing')
|
||||||
|
|
||||||
|
bing_insights_executor = BingInsightsExecutor()
|
||||||
|
_scheduler_instance.register_executor(
|
||||||
|
'bing_insights',
|
||||||
|
bing_insights_executor,
|
||||||
|
load_due_bing_insights_tasks
|
||||||
|
)
|
||||||
|
|
||||||
return _scheduler_instance
|
return _scheduler_instance
|
||||||
|
|
||||||
@@ -57,6 +95,9 @@ __all__ = [
|
|||||||
'TaskExecutionResult',
|
'TaskExecutionResult',
|
||||||
'MonitoringTaskExecutor',
|
'MonitoringTaskExecutor',
|
||||||
'OAuthTokenMonitoringExecutor',
|
'OAuthTokenMonitoringExecutor',
|
||||||
|
'WebsiteAnalysisExecutor',
|
||||||
|
'GSCInsightsExecutor',
|
||||||
|
'BingInsightsExecutor',
|
||||||
'get_scheduler',
|
'get_scheduler',
|
||||||
# Exception handling
|
# Exception handling
|
||||||
'SchedulerExceptionHandler',
|
'SchedulerExceptionHandler',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
from services.database import get_db_session
|
from services.database import get_db_session
|
||||||
from utils.logger_utils import get_service_logger
|
from utils.logger_utils import get_service_logger
|
||||||
from models.scheduler_models import SchedulerEventLog
|
from models.scheduler_models import SchedulerEventLog
|
||||||
|
from models.scheduler_cumulative_stats_model import SchedulerCumulativeStats
|
||||||
from .exception_handler import DatabaseError
|
from .exception_handler import DatabaseError
|
||||||
from .interval_manager import adjust_check_interval_if_needed
|
from .interval_manager import adjust_check_interval_if_needed
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ async def check_and_execute_due_tasks(scheduler: 'TaskScheduler'):
|
|||||||
logger.warning("\n".join(check_lines))
|
logger.warning("\n".join(check_lines))
|
||||||
|
|
||||||
# Save check cycle event to database for historical tracking
|
# Save check cycle event to database for historical tracking
|
||||||
|
event_log_id = None
|
||||||
try:
|
try:
|
||||||
event_log = SchedulerEventLog(
|
event_log = SchedulerEventLog(
|
||||||
event_type='check_cycle',
|
event_type='check_cycle',
|
||||||
@@ -119,11 +121,63 @@ async def check_and_execute_due_tasks(scheduler: 'TaskScheduler'):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.add(event_log)
|
db.add(event_log)
|
||||||
|
db.flush() # Flush to get the ID without committing
|
||||||
|
event_log_id = event_log.id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.debug(f"[Check Cycle] Saved event log with ID: {event_log_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to save check cycle event log: {e}")
|
logger.error(f"[Check Cycle] ❌ Failed to save check cycle event log: {e}", exc_info=True)
|
||||||
if db:
|
if db:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
# Continue execution even if event log save fails
|
||||||
|
|
||||||
|
# Update cumulative stats table (persistent across restarts)
|
||||||
|
try:
|
||||||
|
cumulative_stats = SchedulerCumulativeStats.get_or_create(db)
|
||||||
|
|
||||||
|
# Update cumulative metrics by adding this cycle's values
|
||||||
|
# Get current cycle values (incremental, not total)
|
||||||
|
cycle_tasks_found = cycle_summary.get('total_found', 0)
|
||||||
|
cycle_tasks_executed = cycle_summary.get('total_executed', 0)
|
||||||
|
cycle_tasks_failed = cycle_summary.get('total_failed', 0)
|
||||||
|
|
||||||
|
# Update cumulative totals (additive)
|
||||||
|
cumulative_stats.total_check_cycles += 1
|
||||||
|
cumulative_stats.cumulative_tasks_found += cycle_tasks_found
|
||||||
|
cumulative_stats.cumulative_tasks_executed += cycle_tasks_executed
|
||||||
|
cumulative_stats.cumulative_tasks_failed += cycle_tasks_failed
|
||||||
|
# Note: tasks_skipped in scheduler.stats is a running total, not per-cycle
|
||||||
|
# We track it as-is from scheduler.stats (it's already cumulative)
|
||||||
|
# This ensures we don't double-count skipped tasks
|
||||||
|
if cumulative_stats.cumulative_tasks_skipped is None:
|
||||||
|
cumulative_stats.cumulative_tasks_skipped = 0
|
||||||
|
# Update to current total from scheduler (which is already cumulative)
|
||||||
|
current_skipped = scheduler.stats.get('tasks_skipped', 0)
|
||||||
|
if current_skipped > cumulative_stats.cumulative_tasks_skipped:
|
||||||
|
cumulative_stats.cumulative_tasks_skipped = current_skipped
|
||||||
|
cumulative_stats.last_check_cycle_id = event_log_id
|
||||||
|
cumulative_stats.last_updated = datetime.utcnow()
|
||||||
|
cumulative_stats.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
# Log at DEBUG level to avoid noise during normal operation
|
||||||
|
# This is expected behavior, not a warning
|
||||||
|
logger.debug(
|
||||||
|
f"[Check Cycle] Updated cumulative stats: "
|
||||||
|
f"cycles={cumulative_stats.total_check_cycles}, "
|
||||||
|
f"found={cumulative_stats.cumulative_tasks_found}, "
|
||||||
|
f"executed={cumulative_stats.cumulative_tasks_executed}, "
|
||||||
|
f"failed={cumulative_stats.cumulative_tasks_failed}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Check Cycle] ❌ Failed to update cumulative stats: {e}", exc_info=True)
|
||||||
|
if db:
|
||||||
|
db.rollback()
|
||||||
|
# Log warning but continue - cumulative stats can be rebuilt from event logs
|
||||||
|
logger.warning(
|
||||||
|
"[Check Cycle] ⚠️ Cumulative stats update failed. "
|
||||||
|
"Stats can be rebuilt from event logs on next dashboard load."
|
||||||
|
)
|
||||||
|
|
||||||
# Update last_update timestamp for frontend polling
|
# Update last_update timestamp for frontend polling
|
||||||
scheduler.stats['last_update'] = datetime.utcnow().isoformat()
|
scheduler.stats['last_update'] = datetime.utcnow().isoformat()
|
||||||
|
|||||||
@@ -104,19 +104,16 @@ async def restore_oauth_monitoring_tasks(scheduler):
|
|||||||
# Fallback to users with existing tasks only
|
# Fallback to users with existing tasks only
|
||||||
|
|
||||||
total_created = 0
|
total_created = 0
|
||||||
|
restoration_summary = [] # Collect summary for single log
|
||||||
|
|
||||||
for user_id in users_to_check:
|
for user_id in users_to_check:
|
||||||
try:
|
try:
|
||||||
# Get connected platforms for this user
|
# Get connected platforms for this user (silent - no logging)
|
||||||
connected_platforms = get_connected_platforms(user_id)
|
connected_platforms = get_connected_platforms(user_id)
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
f"[OAuth Task Restoration] User {user_id}: "
|
|
||||||
f"Connected platforms: {connected_platforms}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not connected_platforms:
|
if not connected_platforms:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[OAuth Task Restoration] No connected platforms for user {user_id}, skipping"
|
f"[OAuth Task Restoration] No connected platforms for user {user_id[:20]}..., skipping"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -134,11 +131,6 @@ async def restore_oauth_monitoring_tasks(scheduler):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if missing_platforms:
|
if missing_platforms:
|
||||||
logger.warning(
|
|
||||||
f"[OAuth Task Restoration] ⚠️ User {user_id} has connected platforms "
|
|
||||||
f"{connected_platforms} but missing tasks for: {missing_platforms}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create missing tasks
|
# Create missing tasks
|
||||||
created = create_oauth_monitoring_tasks(
|
created = create_oauth_monitoring_tasks(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -147,15 +139,10 @@ async def restore_oauth_monitoring_tasks(scheduler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
total_created += len(created)
|
total_created += len(created)
|
||||||
|
# Collect summary info instead of logging immediately
|
||||||
logger.warning(
|
platforms_str = ", ".join([p.upper() for p in missing_platforms])
|
||||||
f"[OAuth Task Restoration] ✅ Created {len(created)} missing OAuth tasks "
|
restoration_summary.append(
|
||||||
f"for user {user_id}, platforms: {missing_platforms}"
|
f" ├─ User {user_id[:20]}...: {len(created)} tasks ({platforms_str})"
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[OAuth Task Restoration] ✅ User {user_id} has all required tasks "
|
|
||||||
f"for connected platforms: {connected_platforms}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -173,16 +160,23 @@ async def restore_oauth_monitoring_tasks(scheduler):
|
|||||||
|
|
||||||
final_platform_summary = ", ".join([f"{p}: {c}" for p, c in sorted(final_by_platform.items())])
|
final_platform_summary = ", ".join([f"{p}: {c}" for p, c in sorted(final_by_platform.items())])
|
||||||
|
|
||||||
|
# Single formatted summary log (similar to scheduler startup)
|
||||||
if total_created > 0:
|
if total_created > 0:
|
||||||
|
summary_lines = "\n".join(restoration_summary[:5]) # Show first 5 users
|
||||||
|
if len(restoration_summary) > 5:
|
||||||
|
summary_lines += f"\n └─ ... and {len(restoration_summary) - 5} more users"
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth Task Restoration] ✅ Created {total_created} missing OAuth monitoring tasks. "
|
f"[OAuth Task Restoration] ✅ OAuth Monitoring Tasks Restored\n"
|
||||||
f"Final platform breakdown: {final_platform_summary}"
|
f" ├─ Tasks Created: {total_created}\n"
|
||||||
|
f" ├─ Users Processed: {len(users_to_check)}\n"
|
||||||
|
f" ├─ Platform Breakdown: {final_platform_summary}\n"
|
||||||
|
+ summary_lines
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth Task Restoration] ✅ All users have required OAuth monitoring tasks. "
|
f"[OAuth Task Restoration] ✅ All users have required OAuth monitoring tasks. "
|
||||||
f"Checked {len(users_to_check)} users, found {len(existing_tasks)} existing tasks. "
|
f"Checked {len(users_to_check)} users. Platform breakdown: {final_platform_summary}"
|
||||||
f"Platform breakdown: {final_platform_summary}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Platform Insights Task Restoration
|
||||||
|
Automatically creates missing platform insights tasks for users who have connected platforms
|
||||||
|
but don't have insights tasks created yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
from services.database import get_db_session
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask
|
||||||
|
from services.platform_insights_monitoring_service import create_platform_insights_task
|
||||||
|
from services.oauth_token_monitoring_service import get_connected_platforms
|
||||||
|
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
|
||||||
|
|
||||||
|
logger = get_service_logger("platform_insights_task_restoration")
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_platform_insights_tasks(scheduler):
|
||||||
|
"""
|
||||||
|
Restore/create missing platform insights tasks for all users.
|
||||||
|
|
||||||
|
This checks all users who have connected platforms (GSC/Bing) and ensures they have
|
||||||
|
insights tasks created. Tasks are created for platforms that are:
|
||||||
|
- Connected (detected via get_connected_platforms or OAuth tasks)
|
||||||
|
- Missing insights tasks (no PlatformInsightsTask exists)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scheduler: TaskScheduler instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.warning("[Platform Insights Restoration] Starting platform insights task restoration...")
|
||||||
|
db = get_db_session()
|
||||||
|
if not db:
|
||||||
|
logger.warning("[Platform Insights Restoration] Could not get database session")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all existing insights tasks to find unique user_ids
|
||||||
|
existing_tasks = db.query(PlatformInsightsTask).all()
|
||||||
|
user_ids_with_tasks = set(task.user_id for task in existing_tasks)
|
||||||
|
|
||||||
|
# Get all OAuth tasks to find users with connected platforms
|
||||||
|
oauth_tasks = db.query(OAuthTokenMonitoringTask).all()
|
||||||
|
user_ids_with_oauth = set(task.user_id for task in oauth_tasks)
|
||||||
|
|
||||||
|
# Platforms that support insights (GSC and Bing only)
|
||||||
|
insights_platforms = ['gsc', 'bing']
|
||||||
|
|
||||||
|
# Get users who have OAuth tasks for GSC or Bing
|
||||||
|
users_to_check = set()
|
||||||
|
for task in oauth_tasks:
|
||||||
|
if task.platform in insights_platforms:
|
||||||
|
users_to_check.add(task.user_id)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[Platform Insights Restoration] Found {len(existing_tasks)} existing insights tasks "
|
||||||
|
f"for {len(user_ids_with_tasks)} users. Checking {len(users_to_check)} users "
|
||||||
|
f"with GSC/Bing OAuth connections."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not users_to_check:
|
||||||
|
logger.warning("[Platform Insights Restoration] No users with GSC/Bing connections found")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_created = 0
|
||||||
|
restoration_summary = []
|
||||||
|
|
||||||
|
for user_id in users_to_check:
|
||||||
|
try:
|
||||||
|
# Get connected platforms for this user
|
||||||
|
connected_platforms = get_connected_platforms(user_id)
|
||||||
|
|
||||||
|
# Filter to only GSC and Bing
|
||||||
|
insights_connected = [p for p in connected_platforms if p in insights_platforms]
|
||||||
|
|
||||||
|
if not insights_connected:
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights Restoration] No GSC/Bing connections for user {user_id[:20]}..., skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check which platforms are missing insights tasks
|
||||||
|
existing_platforms = {
|
||||||
|
task.platform
|
||||||
|
for task in existing_tasks
|
||||||
|
if task.user_id == user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_platforms = [
|
||||||
|
platform
|
||||||
|
for platform in insights_connected
|
||||||
|
if platform not in existing_platforms
|
||||||
|
]
|
||||||
|
|
||||||
|
if missing_platforms:
|
||||||
|
# Create missing tasks for each platform
|
||||||
|
for platform in missing_platforms:
|
||||||
|
try:
|
||||||
|
# Don't fetch site_url here - it requires API calls
|
||||||
|
# The executor will fetch it when the task runs (weekly)
|
||||||
|
# This avoids API calls during restoration
|
||||||
|
result = create_platform_insights_task(
|
||||||
|
user_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
site_url=None, # Will be fetched by executor when task runs
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
total_created += 1
|
||||||
|
restoration_summary.append(
|
||||||
|
f" ├─ User {user_id[:20]}...: {platform.upper()} task created"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights Restoration] Failed to create {platform} task "
|
||||||
|
f"for user {user_id}: {result.get('error')}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights Restoration] Error creating {platform} task "
|
||||||
|
f"for user {user_id}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"[Platform Insights Restoration] Error processing user {user_id}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Log summary
|
||||||
|
if total_created > 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[Platform Insights Restoration] ✅ Created {total_created} platform insights tasks:\n" +
|
||||||
|
"\n".join(restoration_summary)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Platform Insights Restoration] ✅ All users have required platform insights tasks. "
|
||||||
|
f"Checked {len(users_to_check)} users, found {len(existing_tasks)} existing tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Platform Insights Restoration] Error during restoration: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ from models.scheduler_models import SchedulerEventLog
|
|||||||
from .interval_manager import determine_optimal_interval, adjust_check_interval_if_needed
|
from .interval_manager import determine_optimal_interval, adjust_check_interval_if_needed
|
||||||
from .job_restoration import restore_persona_jobs
|
from .job_restoration import restore_persona_jobs
|
||||||
from .oauth_task_restoration import restore_oauth_monitoring_tasks
|
from .oauth_task_restoration import restore_oauth_monitoring_tasks
|
||||||
|
from .website_analysis_task_restoration import restore_website_analysis_tasks
|
||||||
|
from .platform_insights_task_restoration import restore_platform_insights_tasks
|
||||||
from .check_cycle_handler import check_and_execute_due_tasks
|
from .check_cycle_handler import check_and_execute_due_tasks
|
||||||
from .task_execution_handler import execute_task_async
|
from .task_execution_handler import execute_task_async
|
||||||
|
|
||||||
@@ -185,6 +187,15 @@ class TaskScheduler:
|
|||||||
# Restore/create missing OAuth token monitoring tasks for connected platforms
|
# Restore/create missing OAuth token monitoring tasks for connected platforms
|
||||||
await restore_oauth_monitoring_tasks(self)
|
await restore_oauth_monitoring_tasks(self)
|
||||||
|
|
||||||
|
# Restore/create missing website analysis tasks for users who completed onboarding
|
||||||
|
await restore_website_analysis_tasks(self)
|
||||||
|
|
||||||
|
# Restore/create missing platform insights tasks for users with connected GSC/Bing
|
||||||
|
await restore_platform_insights_tasks(self)
|
||||||
|
|
||||||
|
# Validate and rebuild cumulative stats if needed
|
||||||
|
await self._validate_and_rebuild_cumulative_stats()
|
||||||
|
|
||||||
# Get all scheduled APScheduler jobs (including one-time tasks)
|
# Get all scheduled APScheduler jobs (including one-time tasks)
|
||||||
all_jobs = self.scheduler.get_jobs()
|
all_jobs = self.scheduler.get_jobs()
|
||||||
registered_types = self.registry.get_registered_types()
|
registered_types = self.registry.get_registered_types()
|
||||||
@@ -260,27 +271,55 @@ class TaskScheduler:
|
|||||||
f"tasks haven't been created. Error type: {type(e).__name__}"
|
f"tasks haven't been created. Error type: {type(e).__name__}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get website analysis tasks count
|
||||||
|
website_analysis_tasks_count = 0
|
||||||
|
try:
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
|
||||||
|
website_analysis_tasks_count = db.query(WebsiteAnalysisTask).filter(
|
||||||
|
WebsiteAnalysisTask.status == 'active'
|
||||||
|
).count()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get website analysis tasks count: {e}")
|
||||||
|
|
||||||
|
# Get platform insights tasks count
|
||||||
|
platform_insights_tasks_count = 0
|
||||||
|
try:
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask
|
||||||
|
platform_insights_tasks_count = db.query(PlatformInsightsTask).filter(
|
||||||
|
PlatformInsightsTask.status == 'active'
|
||||||
|
).count()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get platform insights tasks count: {e}")
|
||||||
|
|
||||||
# Calculate job counts
|
# Calculate job counts
|
||||||
apscheduler_recurring = 1 # check_due_tasks
|
apscheduler_recurring = 1 # check_due_tasks
|
||||||
apscheduler_one_time = len(all_jobs) - 1
|
apscheduler_one_time = len(all_jobs) - 1
|
||||||
total_recurring = apscheduler_recurring + oauth_tasks_count
|
total_recurring = apscheduler_recurring + oauth_tasks_count + website_analysis_tasks_count + platform_insights_tasks_count
|
||||||
total_jobs = len(all_jobs) + oauth_tasks_count
|
total_jobs = len(all_jobs) + oauth_tasks_count + website_analysis_tasks_count + platform_insights_tasks_count
|
||||||
|
|
||||||
# Build comprehensive startup log message
|
# Build comprehensive startup log message
|
||||||
|
recurring_breakdown = f"check_due_tasks: {apscheduler_recurring}"
|
||||||
|
if oauth_tasks_count > 0:
|
||||||
|
recurring_breakdown += f", OAuth monitoring: {oauth_tasks_count}"
|
||||||
|
if website_analysis_tasks_count > 0:
|
||||||
|
recurring_breakdown += f", Website analysis: {website_analysis_tasks_count}"
|
||||||
|
if platform_insights_tasks_count > 0:
|
||||||
|
recurring_breakdown += f", Platform insights: {platform_insights_tasks_count}"
|
||||||
|
|
||||||
startup_lines = [
|
startup_lines = [
|
||||||
f"[Scheduler] ✅ Task Scheduler Started",
|
f"[Scheduler] ✅ Task Scheduler Started",
|
||||||
f" ├─ Check Interval: {initial_interval} minutes",
|
f" ├─ Check Interval: {initial_interval} minutes",
|
||||||
f" ├─ Registered Task Types: {len(registered_types)} ({', '.join(registered_types) if registered_types else 'none'})",
|
f" ├─ Registered Task Types: {len(registered_types)} ({', '.join(registered_types) if registered_types else 'none'})",
|
||||||
f" ├─ Active Strategies: {active_strategies}",
|
f" ├─ Active Strategies: {active_strategies}",
|
||||||
f" ├─ Total Scheduled Jobs: {total_jobs}",
|
f" ├─ Total Scheduled Jobs: {total_jobs}",
|
||||||
f" ├─ Recurring Jobs: {total_recurring} (check_due_tasks: {apscheduler_recurring}, OAuth monitoring: {oauth_tasks_count})",
|
f" ├─ Recurring Jobs: {total_recurring} ({recurring_breakdown})",
|
||||||
f" └─ One-Time Jobs: {apscheduler_one_time}"
|
f" └─ One-Time Jobs: {apscheduler_one_time}"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add APScheduler job details
|
# Add APScheduler job details
|
||||||
if all_jobs:
|
if all_jobs:
|
||||||
for idx, job in enumerate(all_jobs):
|
for idx, job in enumerate(all_jobs):
|
||||||
is_last = idx == len(all_jobs) - 1 and oauth_tasks_count == 0
|
is_last = idx == len(all_jobs) - 1 and oauth_tasks_count == 0 and website_analysis_tasks_count == 0 and platform_insights_tasks_count == 0
|
||||||
prefix = " └─" if is_last else " ├─"
|
prefix = " └─" if is_last else " ├─"
|
||||||
next_run = job.next_run_time
|
next_run = job.next_run_time
|
||||||
trigger_type = type(job.trigger).__name__
|
trigger_type = type(job.trigger).__name__
|
||||||
@@ -338,7 +377,7 @@ class TaskScheduler:
|
|||||||
oauth_tasks = db.query(OAuthTokenMonitoringTask).all()
|
oauth_tasks = db.query(OAuthTokenMonitoringTask).all()
|
||||||
|
|
||||||
for idx, task in enumerate(oauth_tasks):
|
for idx, task in enumerate(oauth_tasks):
|
||||||
is_last = idx == len(oauth_tasks) - 1 and len(all_jobs) == 0
|
is_last = idx == len(oauth_tasks) - 1 and website_analysis_tasks_count == 0 and platform_insights_tasks_count == 0 and len(all_jobs) == 0
|
||||||
prefix = " └─" if is_last else " ├─"
|
prefix = " └─" if is_last else " ├─"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -367,6 +406,71 @@ class TaskScheduler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not get OAuth token monitoring task details: {e}")
|
logger.debug(f"Could not get OAuth token monitoring task details: {e}")
|
||||||
|
|
||||||
|
# Add website analysis tasks details
|
||||||
|
if website_analysis_tasks_count > 0:
|
||||||
|
try:
|
||||||
|
db = get_db_session()
|
||||||
|
if db:
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
|
||||||
|
website_analysis_tasks = db.query(WebsiteAnalysisTask).all()
|
||||||
|
|
||||||
|
for idx, task in enumerate(website_analysis_tasks):
|
||||||
|
is_last = idx == len(website_analysis_tasks) - 1 and platform_insights_tasks_count == 0 and len(all_jobs) == 0 and total_oauth_tasks == 0
|
||||||
|
prefix = " └─" if is_last else " ├─"
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_job_store = get_user_job_store_name(task.user_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not extract job store name for user {task.user_id}: {e}")
|
||||||
|
user_job_store = 'default'
|
||||||
|
|
||||||
|
next_check = task.next_check.isoformat() if task.next_check else 'Not scheduled'
|
||||||
|
frequency = f"Every {task.frequency_days} days"
|
||||||
|
task_type_label = "User Website" if task.task_type == 'user_website' else "Competitor"
|
||||||
|
status_indicator = "✅" if task.status == 'active' else f"[{task.status}]"
|
||||||
|
website_display = task.website_url[:50] + "..." if task.website_url and len(task.website_url) > 50 else (task.website_url or 'N/A')
|
||||||
|
|
||||||
|
startup_lines.append(
|
||||||
|
f"{prefix} Job: website_analysis_{task.task_type}_{task.user_id}_{task.id} | "
|
||||||
|
f"Trigger: CronTrigger ({frequency}) | Next Run: {next_check} | "
|
||||||
|
f"User: {task.user_id} | Store: {user_job_store} | Type: {task_type_label} | URL: {website_display} {status_indicator}"
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get website analysis task details: {e}")
|
||||||
|
|
||||||
|
# Add platform insights tasks details
|
||||||
|
if platform_insights_tasks_count > 0:
|
||||||
|
try:
|
||||||
|
db = get_db_session()
|
||||||
|
if db:
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask
|
||||||
|
platform_insights_tasks = db.query(PlatformInsightsTask).all()
|
||||||
|
|
||||||
|
for idx, task in enumerate(platform_insights_tasks):
|
||||||
|
is_last = idx == len(platform_insights_tasks) - 1 and len(all_jobs) == 0 and total_oauth_tasks == 0 and website_analysis_tasks_count == 0
|
||||||
|
prefix = " └─" if is_last else " ├─"
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_job_store = get_user_job_store_name(task.user_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not extract job store name for user {task.user_id}: {e}")
|
||||||
|
user_job_store = 'default'
|
||||||
|
|
||||||
|
next_check = task.next_check.isoformat() if task.next_check else 'Not scheduled'
|
||||||
|
platform_label = task.platform.upper() if task.platform else 'Unknown'
|
||||||
|
site_display = task.site_url[:50] + "..." if task.site_url and len(task.site_url) > 50 else (task.site_url or 'N/A')
|
||||||
|
status_indicator = "✅" if task.status == 'active' else f"[{task.status}]"
|
||||||
|
|
||||||
|
startup_lines.append(
|
||||||
|
f"{prefix} Job: platform_insights_{task.platform}_{task.user_id} | "
|
||||||
|
f"Trigger: CronTrigger (Weekly) | Next Run: {next_check} | "
|
||||||
|
f"User: {task.user_id} | Store: {user_job_store} | Platform: {platform_label} | Site: {site_display} {status_indicator}"
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get platform insights task details: {e}")
|
||||||
|
|
||||||
# Log comprehensive startup information in single message
|
# Log comprehensive startup information in single message
|
||||||
logger.warning("\n".join(startup_lines))
|
logger.warning("\n".join(startup_lines))
|
||||||
|
|
||||||
@@ -384,7 +488,9 @@ class TaskScheduler:
|
|||||||
'total_jobs': total_jobs,
|
'total_jobs': total_jobs,
|
||||||
'recurring_jobs': total_recurring,
|
'recurring_jobs': total_recurring,
|
||||||
'one_time_jobs': apscheduler_one_time,
|
'one_time_jobs': apscheduler_one_time,
|
||||||
'oauth_monitoring_tasks': oauth_tasks_count
|
'oauth_monitoring_tasks': oauth_tasks_count,
|
||||||
|
'website_analysis_tasks': website_analysis_tasks_count,
|
||||||
|
'platform_insights_tasks': platform_insights_tasks_count
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.add(event_log)
|
db.add(event_log)
|
||||||
@@ -533,6 +639,128 @@ class TaskScheduler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error triggering interval adjustment: {e}")
|
logger.warning(f"Error triggering interval adjustment: {e}")
|
||||||
|
|
||||||
|
async def _validate_and_rebuild_cumulative_stats(self):
|
||||||
|
"""
|
||||||
|
Validate cumulative stats on scheduler startup and rebuild if needed.
|
||||||
|
This ensures cumulative stats are accurate after restarts.
|
||||||
|
"""
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = get_db_session()
|
||||||
|
if not db:
|
||||||
|
logger.warning("[Scheduler] Could not get database session for cumulative stats validation")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models.scheduler_cumulative_stats_model import SchedulerCumulativeStats
|
||||||
|
from models.scheduler_models import SchedulerEventLog
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
# Get cumulative stats from persistent table
|
||||||
|
cumulative_stats = db.query(SchedulerCumulativeStats).filter(
|
||||||
|
SchedulerCumulativeStats.id == 1
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Count check_cycle events in database
|
||||||
|
check_cycle_count = db.query(func.count(SchedulerEventLog.id)).filter(
|
||||||
|
SchedulerEventLog.event_type == 'check_cycle'
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
if cumulative_stats:
|
||||||
|
# Validate: cumulative stats should match event log count
|
||||||
|
if cumulative_stats.total_check_cycles != check_cycle_count:
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] ⚠️ Cumulative stats validation failed on startup: "
|
||||||
|
f"cumulative_stats.total_check_cycles={cumulative_stats.total_check_cycles} "
|
||||||
|
f"vs event_logs.count={check_cycle_count}. "
|
||||||
|
f"Rebuilding cumulative stats from event logs..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rebuild from event logs
|
||||||
|
result = db.query(
|
||||||
|
func.count(SchedulerEventLog.id),
|
||||||
|
func.sum(SchedulerEventLog.tasks_found),
|
||||||
|
func.sum(SchedulerEventLog.tasks_executed),
|
||||||
|
func.sum(SchedulerEventLog.tasks_failed)
|
||||||
|
).filter(
|
||||||
|
SchedulerEventLog.event_type == 'check_cycle'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
total_cycles = result[0] if result[0] is not None else 0
|
||||||
|
total_found = result[1] if result[1] is not None else 0
|
||||||
|
total_executed = result[2] if result[2] is not None else 0
|
||||||
|
total_failed = result[3] if result[3] is not None else 0
|
||||||
|
|
||||||
|
# Update cumulative stats
|
||||||
|
cumulative_stats.total_check_cycles = int(total_cycles)
|
||||||
|
cumulative_stats.cumulative_tasks_found = int(total_found)
|
||||||
|
cumulative_stats.cumulative_tasks_executed = int(total_executed)
|
||||||
|
cumulative_stats.cumulative_tasks_failed = int(total_failed)
|
||||||
|
cumulative_stats.last_updated = datetime.utcnow()
|
||||||
|
cumulative_stats.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] ✅ Rebuilt cumulative stats on startup: "
|
||||||
|
f"cycles={total_cycles}, found={total_found}, "
|
||||||
|
f"executed={total_executed}, failed={total_failed}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("[Scheduler] No check_cycle events found to rebuild from")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] ✅ Cumulative stats validated: "
|
||||||
|
f"{cumulative_stats.total_check_cycles} check cycles match event logs"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Cumulative stats table doesn't exist, create it from event logs
|
||||||
|
logger.warning(
|
||||||
|
"[Scheduler] Cumulative stats table not found. "
|
||||||
|
"Creating from event logs..."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.query(
|
||||||
|
func.count(SchedulerEventLog.id),
|
||||||
|
func.sum(SchedulerEventLog.tasks_found),
|
||||||
|
func.sum(SchedulerEventLog.tasks_executed),
|
||||||
|
func.sum(SchedulerEventLog.tasks_failed)
|
||||||
|
).filter(
|
||||||
|
SchedulerEventLog.event_type == 'check_cycle'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
total_cycles = result[0] if result[0] is not None else 0
|
||||||
|
total_found = result[1] if result[1] is not None else 0
|
||||||
|
total_executed = result[2] if result[2] is not None else 0
|
||||||
|
total_failed = result[3] if result[3] is not None else 0
|
||||||
|
|
||||||
|
cumulative_stats = SchedulerCumulativeStats.get_or_create(db)
|
||||||
|
cumulative_stats.total_check_cycles = int(total_cycles)
|
||||||
|
cumulative_stats.cumulative_tasks_found = int(total_found)
|
||||||
|
cumulative_stats.cumulative_tasks_executed = int(total_executed)
|
||||||
|
cumulative_stats.cumulative_tasks_failed = int(total_failed)
|
||||||
|
cumulative_stats.last_updated = datetime.utcnow()
|
||||||
|
cumulative_stats.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] ✅ Created cumulative stats from event logs: "
|
||||||
|
f"cycles={total_cycles}, found={total_found}, "
|
||||||
|
f"executed={total_executed}, failed={total_failed}"
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(
|
||||||
|
"[Scheduler] Cumulative stats model not available. "
|
||||||
|
"Migration may not have been run yet. "
|
||||||
|
"Run: python backend/scripts/run_cumulative_stats_migration.py"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Error validating cumulative stats: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if db:
|
||||||
|
db.close()
|
||||||
|
|
||||||
async def _process_task_type(self, task_type: str, db: Session, cycle_summary: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
async def _process_task_type(self, task_type: str, db: Session, cycle_summary: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Process due tasks for a specific task type.
|
Process due tasks for a specific task type.
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Website Analysis Task Restoration
|
||||||
|
Automatically creates missing website analysis tasks for users who completed onboarding
|
||||||
|
but don't have monitoring tasks created yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
from services.database import get_db_session
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
|
||||||
|
from services.website_analysis_monitoring_service import create_website_analysis_tasks
|
||||||
|
from models.onboarding import OnboardingSession
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
# Use service logger for consistent logging (WARNING level visible in production)
|
||||||
|
logger = get_service_logger("website_analysis_restoration")
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_website_analysis_tasks(scheduler):
|
||||||
|
"""
|
||||||
|
Restore/create missing website analysis tasks for all users.
|
||||||
|
|
||||||
|
This checks all users who completed onboarding and ensures they have
|
||||||
|
website analysis tasks created. Tasks are created for:
|
||||||
|
- User's website (if analysis exists)
|
||||||
|
- All competitors (from onboarding step 3)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scheduler: TaskScheduler instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.warning("[Website Analysis Restoration] Starting website analysis task restoration...")
|
||||||
|
db = get_db_session()
|
||||||
|
if not db:
|
||||||
|
logger.warning("[Website Analysis Restoration] Could not get database session")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table exists (may not exist if migration hasn't run)
|
||||||
|
try:
|
||||||
|
existing_tasks = db.query(WebsiteAnalysisTask).all()
|
||||||
|
except Exception as table_error:
|
||||||
|
logger.error(
|
||||||
|
f"[Website Analysis Restoration] ⚠️ WebsiteAnalysisTask table may not exist: {table_error}. "
|
||||||
|
f"Please run database migration: create_website_analysis_monitoring_tables.sql"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_ids_with_tasks = set(task.user_id for task in existing_tasks)
|
||||||
|
|
||||||
|
# Log existing tasks breakdown by type
|
||||||
|
existing_by_type = {}
|
||||||
|
for task in existing_tasks:
|
||||||
|
existing_by_type[task.task_type] = existing_by_type.get(task.task_type, 0) + 1
|
||||||
|
|
||||||
|
type_summary = ", ".join([f"{t}: {c}" for t, c in sorted(existing_by_type.items())])
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] Found {len(existing_tasks)} existing website analysis tasks "
|
||||||
|
f"for {len(user_ids_with_tasks)} users. Types: {type_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check users who already have at least one website analysis task
|
||||||
|
users_to_check = list(user_ids_with_tasks)
|
||||||
|
|
||||||
|
# Also query all users from onboarding who completed step 2 (website analysis)
|
||||||
|
# to catch users who completed onboarding but tasks weren't created
|
||||||
|
# Use the same pattern as OnboardingProgressService.get_onboarding_status()
|
||||||
|
# Completion is tracked by: current_step >= 6 OR progress >= 100.0
|
||||||
|
# This matches the logic used in home page redirect and persona generation checks
|
||||||
|
try:
|
||||||
|
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||||
|
from models.onboarding import OnboardingSession
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
# Get onboarding progress service (same as used throughout the app)
|
||||||
|
progress_service = get_onboarding_progress_service()
|
||||||
|
|
||||||
|
# Query all sessions and filter using the same completion logic as the service
|
||||||
|
# This matches the pattern in OnboardingProgressService.get_onboarding_status():
|
||||||
|
# is_completed = (session.current_step >= 6) or (session.progress >= 100.0)
|
||||||
|
completed_sessions = db.query(OnboardingSession).filter(
|
||||||
|
or_(
|
||||||
|
OnboardingSession.current_step >= 6,
|
||||||
|
OnboardingSession.progress >= 100.0
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Validate using the service method for consistency
|
||||||
|
onboarding_user_ids = set()
|
||||||
|
for session in completed_sessions:
|
||||||
|
# Use the same service method as the rest of the app
|
||||||
|
status = progress_service.get_onboarding_status(session.user_id)
|
||||||
|
if status.get('is_completed', False):
|
||||||
|
onboarding_user_ids.add(session.user_id)
|
||||||
|
|
||||||
|
all_user_ids = users_to_check.copy()
|
||||||
|
|
||||||
|
# Add users from onboarding who might not have tasks yet
|
||||||
|
for user_id in onboarding_user_ids:
|
||||||
|
if user_id not in all_user_ids:
|
||||||
|
all_user_ids.append(user_id)
|
||||||
|
|
||||||
|
users_to_check = all_user_ids
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] Checking {len(users_to_check)} users "
|
||||||
|
f"({len(user_ids_with_tasks)} with existing tasks, "
|
||||||
|
f"{len(onboarding_user_ids)} from onboarding sessions, "
|
||||||
|
f"{len(onboarding_user_ids) - len(user_ids_with_tasks)} new users to check)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Website Analysis Restoration] Could not query onboarding users: {e}")
|
||||||
|
# Fallback to users with existing tasks only
|
||||||
|
users_to_check = list(user_ids_with_tasks)
|
||||||
|
|
||||||
|
total_created = 0
|
||||||
|
users_processed = 0
|
||||||
|
|
||||||
|
for user_id in users_to_check:
|
||||||
|
try:
|
||||||
|
users_processed += 1
|
||||||
|
|
||||||
|
# Check if user already has tasks
|
||||||
|
existing_user_tasks = [
|
||||||
|
task for task in existing_tasks
|
||||||
|
if task.user_id == user_id
|
||||||
|
]
|
||||||
|
|
||||||
|
if existing_user_tasks:
|
||||||
|
logger.debug(
|
||||||
|
f"[Website Analysis Restoration] User {user_id} already has "
|
||||||
|
f"{len(existing_user_tasks)} website analysis tasks, skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] ⚠️ User {user_id} completed onboarding "
|
||||||
|
f"but has no website analysis tasks. Creating tasks..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create missing tasks
|
||||||
|
result = create_website_analysis_tasks(user_id=user_id, db=db)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
tasks_count = result.get('tasks_created', 0)
|
||||||
|
total_created += tasks_count
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] ✅ Created {tasks_count} website analysis tasks "
|
||||||
|
f"for user {user_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error = result.get('error', 'Unknown error')
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] ⚠️ Could not create tasks for user {user_id}: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] Error checking/creating tasks for user {user_id}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Final summary log
|
||||||
|
final_existing_tasks = db.query(WebsiteAnalysisTask).all()
|
||||||
|
final_by_type = {}
|
||||||
|
for task in final_existing_tasks:
|
||||||
|
final_by_type[task.task_type] = final_by_type.get(task.task_type, 0) + 1
|
||||||
|
|
||||||
|
final_type_summary = ", ".join([f"{t}: {c}" for t, c in sorted(final_by_type.items())])
|
||||||
|
|
||||||
|
if total_created > 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] ✅ Created {total_created} missing website analysis tasks. "
|
||||||
|
f"Processed {users_processed} users. Final type breakdown: {final_type_summary}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Restoration] ✅ All users have required website analysis tasks. "
|
||||||
|
f"Checked {users_processed} users, found {len(existing_tasks)} existing tasks. "
|
||||||
|
f"Type breakdown: {final_type_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[Website Analysis Restoration] Error restoring website analysis tasks: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
298
backend/services/scheduler/executors/bing_insights_executor.py
Normal file
298
backend/services/scheduler/executors/bing_insights_executor.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
Bing Insights Task Executor
|
||||||
|
Handles execution of Bing insights fetch tasks for connected platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..core.executor_interface import TaskExecutor, TaskExecutionResult
|
||||||
|
from ..core.exception_handler import TaskExecutionError, DatabaseError, SchedulerExceptionHandler
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask, PlatformInsightsExecutionLog
|
||||||
|
from services.bing_analytics_storage_service import BingAnalyticsStorageService
|
||||||
|
from services.integrations.bing_oauth import BingOAuthService
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
logger = get_service_logger("bing_insights_executor")
|
||||||
|
|
||||||
|
|
||||||
|
class BingInsightsExecutor(TaskExecutor):
|
||||||
|
"""
|
||||||
|
Executor for Bing insights fetch tasks.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Fetching Bing insights data weekly
|
||||||
|
- On first run: Loads existing cached data
|
||||||
|
- On subsequent runs: Fetches fresh data from Bing API
|
||||||
|
- Logging results and updating task status
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logger
|
||||||
|
self.exception_handler = SchedulerExceptionHandler()
|
||||||
|
database_url = os.getenv('DATABASE_URL', 'sqlite:///alwrity.db')
|
||||||
|
self.storage_service = BingAnalyticsStorageService(database_url)
|
||||||
|
self.bing_oauth = BingOAuthService()
|
||||||
|
|
||||||
|
async def execute_task(self, task: PlatformInsightsTask, db: Session) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute a Bing insights fetch task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: PlatformInsightsTask instance
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskExecutionResult
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
user_id = task.user_id
|
||||||
|
site_url = task.site_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(
|
||||||
|
f"Executing Bing insights fetch: task_id={task.id} | "
|
||||||
|
f"user_id={user_id} | site_url={site_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create execution log
|
||||||
|
execution_log = PlatformInsightsExecutionLog(
|
||||||
|
task_id=task.id,
|
||||||
|
execution_date=datetime.utcnow(),
|
||||||
|
status='running'
|
||||||
|
)
|
||||||
|
db.add(execution_log)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Fetch insights
|
||||||
|
result = await self._fetch_insights(task, db)
|
||||||
|
|
||||||
|
# Update execution log
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
execution_log.status = 'success' if result.success else 'failed'
|
||||||
|
execution_log.result_data = result.result_data
|
||||||
|
execution_log.error_message = result.error_message
|
||||||
|
execution_log.execution_time_ms = execution_time_ms
|
||||||
|
execution_log.data_source = result.result_data.get('data_source') if result.success else None
|
||||||
|
|
||||||
|
# Update task based on result
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
task.last_success = datetime.utcnow()
|
||||||
|
task.status = 'active'
|
||||||
|
task.failure_reason = None
|
||||||
|
# Schedule next check (7 days from now)
|
||||||
|
task.next_check = self.calculate_next_execution(
|
||||||
|
task=task,
|
||||||
|
frequency='Weekly',
|
||||||
|
last_execution=task.last_check
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = result.error_message
|
||||||
|
task.status = 'failed'
|
||||||
|
# Schedule retry in 1 day
|
||||||
|
task.next_check = datetime.utcnow() + timedelta(days=1)
|
||||||
|
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Set database session for exception handler
|
||||||
|
self.exception_handler.db = db
|
||||||
|
|
||||||
|
error_result = self.exception_handler.handle_task_execution_error(
|
||||||
|
task=task,
|
||||||
|
error=e,
|
||||||
|
execution_time_ms=execution_time_ms,
|
||||||
|
context="Bing insights fetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update task
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = str(e)
|
||||||
|
task.status = 'failed'
|
||||||
|
task.next_check = datetime.utcnow() + timedelta(days=1)
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return error_result
|
||||||
|
|
||||||
|
async def _fetch_insights(self, task: PlatformInsightsTask, db: Session) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Fetch Bing insights data.
|
||||||
|
|
||||||
|
On first run (no last_success), loads cached data.
|
||||||
|
On subsequent runs, fetches fresh data from API.
|
||||||
|
"""
|
||||||
|
user_id = task.user_id
|
||||||
|
site_url = task.site_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if this is first run (no previous success)
|
||||||
|
is_first_run = task.last_success is None
|
||||||
|
|
||||||
|
if is_first_run:
|
||||||
|
# First run: Try to load from cache
|
||||||
|
self.logger.info(f"First run for Bing insights task {task.id} - loading cached data")
|
||||||
|
cached_data = self._load_cached_data(user_id, site_url)
|
||||||
|
|
||||||
|
if cached_data:
|
||||||
|
self.logger.info(f"Loaded cached Bing data for user {user_id}")
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=True,
|
||||||
|
result_data={
|
||||||
|
'data_source': 'cached',
|
||||||
|
'insights': cached_data,
|
||||||
|
'message': 'Loaded from cached data (first run)'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No cached data - try to fetch from API
|
||||||
|
self.logger.info(f"No cached data found, fetching from Bing API")
|
||||||
|
return await self._fetch_fresh_data(user_id, site_url)
|
||||||
|
else:
|
||||||
|
# Subsequent run: Always fetch fresh data
|
||||||
|
self.logger.info(f"Subsequent run for Bing insights task {task.id} - fetching fresh data")
|
||||||
|
return await self._fetch_fresh_data(user_id, site_url)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching Bing insights for user {user_id}: {e}", exc_info=True)
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"Failed to fetch Bing insights: {str(e)}",
|
||||||
|
result_data={'error': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_cached_data(self, user_id: str, site_url: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load most recent cached Bing data from database."""
|
||||||
|
try:
|
||||||
|
# Get analytics summary from storage service
|
||||||
|
summary = self.storage_service.get_analytics_summary(
|
||||||
|
user_id=user_id,
|
||||||
|
site_url=site_url or '',
|
||||||
|
days=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if summary and isinstance(summary, dict):
|
||||||
|
self.logger.info(f"Found cached Bing data for user {user_id}")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error loading cached Bing data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_fresh_data(self, user_id: str, site_url: Optional[str]) -> TaskExecutionResult:
|
||||||
|
"""Fetch fresh Bing insights from API."""
|
||||||
|
try:
|
||||||
|
# Check if user has active tokens
|
||||||
|
token_status = self.bing_oauth.get_user_token_status(user_id)
|
||||||
|
|
||||||
|
if not token_status.get('has_active_tokens'):
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Bing Webmaster tokens not available or expired",
|
||||||
|
result_data={'error': 'No active tokens'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user's sites
|
||||||
|
sites = self.bing_oauth.get_user_sites(user_id)
|
||||||
|
|
||||||
|
if not sites:
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="No Bing Webmaster sites found",
|
||||||
|
result_data={'error': 'No sites found'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use provided site_url or first site
|
||||||
|
if not site_url:
|
||||||
|
site_url = sites[0].get('Url', '') if isinstance(sites[0], dict) else sites[0]
|
||||||
|
|
||||||
|
# Get active token
|
||||||
|
active_tokens = token_status.get('active_tokens', [])
|
||||||
|
if not active_tokens:
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="No active Bing Webmaster tokens",
|
||||||
|
result_data={'error': 'No tokens'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# For now, use stored analytics data (Bing API integration can be added later)
|
||||||
|
# This ensures we have data available even if the API class doesn't exist yet
|
||||||
|
summary = self.storage_service.get_analytics_summary(user_id, site_url, days=30)
|
||||||
|
|
||||||
|
if summary and isinstance(summary, dict):
|
||||||
|
# Format insights data from stored analytics
|
||||||
|
insights_data = {
|
||||||
|
'site_url': site_url,
|
||||||
|
'date_range': {
|
||||||
|
'start': (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'),
|
||||||
|
'end': datetime.now().strftime('%Y-%m-%d')
|
||||||
|
},
|
||||||
|
'summary': summary.get('summary', {}),
|
||||||
|
'fetched_at': datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Successfully loaded Bing insights from storage for user {user_id}, site {site_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=True,
|
||||||
|
result_data={
|
||||||
|
'data_source': 'storage',
|
||||||
|
'insights': insights_data,
|
||||||
|
'message': 'Loaded from stored analytics data'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No stored data available
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="No Bing analytics data available. Data will be collected during next onboarding refresh.",
|
||||||
|
result_data={'error': 'No stored data available'}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching fresh Bing data: {e}", exc_info=True)
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"API fetch failed: {str(e)}",
|
||||||
|
result_data={'error': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def calculate_next_execution(
|
||||||
|
self,
|
||||||
|
task: PlatformInsightsTask,
|
||||||
|
frequency: str,
|
||||||
|
last_execution: Optional[datetime] = None
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next execution time based on frequency.
|
||||||
|
|
||||||
|
For platform insights, frequency is always 'Weekly' (7 days).
|
||||||
|
"""
|
||||||
|
if last_execution is None:
|
||||||
|
last_execution = datetime.utcnow()
|
||||||
|
|
||||||
|
if frequency == 'Weekly':
|
||||||
|
return last_execution + timedelta(days=7)
|
||||||
|
elif frequency == 'Daily':
|
||||||
|
return last_execution + timedelta(days=1)
|
||||||
|
else:
|
||||||
|
# Default to weekly
|
||||||
|
return last_execution + timedelta(days=7)
|
||||||
|
|
||||||
307
backend/services/scheduler/executors/gsc_insights_executor.py
Normal file
307
backend/services/scheduler/executors/gsc_insights_executor.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
GSC Insights Task Executor
|
||||||
|
Handles execution of GSC insights fetch tasks for connected platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from ..core.executor_interface import TaskExecutor, TaskExecutionResult
|
||||||
|
from ..core.exception_handler import TaskExecutionError, DatabaseError, SchedulerExceptionHandler
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask, PlatformInsightsExecutionLog
|
||||||
|
from services.gsc_service import GSCService
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
logger = get_service_logger("gsc_insights_executor")
|
||||||
|
|
||||||
|
|
||||||
|
class GSCInsightsExecutor(TaskExecutor):
|
||||||
|
"""
|
||||||
|
Executor for GSC insights fetch tasks.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Fetching GSC insights data weekly
|
||||||
|
- On first run: Loads existing cached data
|
||||||
|
- On subsequent runs: Fetches fresh data from GSC API
|
||||||
|
- Logging results and updating task status
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logger
|
||||||
|
self.exception_handler = SchedulerExceptionHandler()
|
||||||
|
self.gsc_service = GSCService()
|
||||||
|
|
||||||
|
async def execute_task(self, task: PlatformInsightsTask, db: Session) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute a GSC insights fetch task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: PlatformInsightsTask instance
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskExecutionResult
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
user_id = task.user_id
|
||||||
|
site_url = task.site_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(
|
||||||
|
f"Executing GSC insights fetch: task_id={task.id} | "
|
||||||
|
f"user_id={user_id} | site_url={site_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create execution log
|
||||||
|
execution_log = PlatformInsightsExecutionLog(
|
||||||
|
task_id=task.id,
|
||||||
|
execution_date=datetime.utcnow(),
|
||||||
|
status='running'
|
||||||
|
)
|
||||||
|
db.add(execution_log)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Fetch insights
|
||||||
|
result = await self._fetch_insights(task, db)
|
||||||
|
|
||||||
|
# Update execution log
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
execution_log.status = 'success' if result.success else 'failed'
|
||||||
|
execution_log.result_data = result.result_data
|
||||||
|
execution_log.error_message = result.error_message
|
||||||
|
execution_log.execution_time_ms = execution_time_ms
|
||||||
|
execution_log.data_source = result.result_data.get('data_source') if result.success else None
|
||||||
|
|
||||||
|
# Update task based on result
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
task.last_success = datetime.utcnow()
|
||||||
|
task.status = 'active'
|
||||||
|
task.failure_reason = None
|
||||||
|
# Schedule next check (7 days from now)
|
||||||
|
task.next_check = self.calculate_next_execution(
|
||||||
|
task=task,
|
||||||
|
frequency='Weekly',
|
||||||
|
last_execution=task.last_check
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = result.error_message
|
||||||
|
task.status = 'failed'
|
||||||
|
# Schedule retry in 1 day
|
||||||
|
task.next_check = datetime.utcnow() + timedelta(days=1)
|
||||||
|
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Set database session for exception handler
|
||||||
|
self.exception_handler.db = db
|
||||||
|
|
||||||
|
error_result = self.exception_handler.handle_task_execution_error(
|
||||||
|
task=task,
|
||||||
|
error=e,
|
||||||
|
execution_time_ms=execution_time_ms,
|
||||||
|
context="GSC insights fetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update task
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = str(e)
|
||||||
|
task.status = 'failed'
|
||||||
|
task.next_check = datetime.utcnow() + timedelta(days=1)
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return error_result
|
||||||
|
|
||||||
|
async def _fetch_insights(self, task: PlatformInsightsTask, db: Session) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Fetch GSC insights data.
|
||||||
|
|
||||||
|
On first run (no last_success), loads cached data.
|
||||||
|
On subsequent runs, fetches fresh data from API.
|
||||||
|
"""
|
||||||
|
user_id = task.user_id
|
||||||
|
site_url = task.site_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if this is first run (no previous success)
|
||||||
|
is_first_run = task.last_success is None
|
||||||
|
|
||||||
|
if is_first_run:
|
||||||
|
# First run: Try to load from cache
|
||||||
|
self.logger.info(f"First run for GSC insights task {task.id} - loading cached data")
|
||||||
|
cached_data = self._load_cached_data(user_id, site_url)
|
||||||
|
|
||||||
|
if cached_data:
|
||||||
|
self.logger.info(f"Loaded cached GSC data for user {user_id}")
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=True,
|
||||||
|
result_data={
|
||||||
|
'data_source': 'cached',
|
||||||
|
'insights': cached_data,
|
||||||
|
'message': 'Loaded from cached data (first run)'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No cached data - try to fetch from API
|
||||||
|
self.logger.info(f"No cached data found, fetching from GSC API")
|
||||||
|
return await self._fetch_fresh_data(user_id, site_url)
|
||||||
|
else:
|
||||||
|
# Subsequent run: Always fetch fresh data
|
||||||
|
self.logger.info(f"Subsequent run for GSC insights task {task.id} - fetching fresh data")
|
||||||
|
return await self._fetch_fresh_data(user_id, site_url)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching GSC insights for user {user_id}: {e}", exc_info=True)
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"Failed to fetch GSC insights: {str(e)}",
|
||||||
|
result_data={'error': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_cached_data(self, user_id: str, site_url: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load most recent cached GSC data from database."""
|
||||||
|
try:
|
||||||
|
db_path = self.gsc_service.db_path
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find most recent cached data
|
||||||
|
if site_url:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT data_json, created_at
|
||||||
|
FROM gsc_data_cache
|
||||||
|
WHERE user_id = ? AND site_url = ? AND data_type = 'analytics'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
''', (user_id, site_url))
|
||||||
|
else:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT data_json, created_at
|
||||||
|
FROM gsc_data_cache
|
||||||
|
WHERE user_id = ? AND data_type = 'analytics'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
data_json, created_at = result
|
||||||
|
insights_data = json.loads(data_json) if isinstance(data_json, str) else data_json
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Found cached GSC data from {created_at} for user {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return insights_data
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error loading cached GSC data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_fresh_data(self, user_id: str, site_url: Optional[str]) -> TaskExecutionResult:
|
||||||
|
"""Fetch fresh GSC insights from API."""
|
||||||
|
try:
|
||||||
|
# If no site_url, get first site
|
||||||
|
if not site_url:
|
||||||
|
sites = self.gsc_service.get_site_list(user_id)
|
||||||
|
if not sites:
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message="No GSC sites found for user",
|
||||||
|
result_data={'error': 'No sites found'}
|
||||||
|
)
|
||||||
|
site_url = sites[0]['siteUrl']
|
||||||
|
|
||||||
|
# Get analytics for last 30 days
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Fetch search analytics
|
||||||
|
search_analytics = self.gsc_service.get_search_analytics(
|
||||||
|
user_id=user_id,
|
||||||
|
site_url=site_url,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'error' in search_analytics:
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=search_analytics.get('error', 'Unknown error'),
|
||||||
|
result_data=search_analytics
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format insights data
|
||||||
|
insights_data = {
|
||||||
|
'site_url': site_url,
|
||||||
|
'date_range': {
|
||||||
|
'start': start_date,
|
||||||
|
'end': end_date
|
||||||
|
},
|
||||||
|
'overall_metrics': search_analytics.get('overall_metrics', {}),
|
||||||
|
'query_data': search_analytics.get('query_data', {}),
|
||||||
|
'fetched_at': datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Successfully fetched GSC insights for user {user_id}, site {site_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=True,
|
||||||
|
result_data={
|
||||||
|
'data_source': 'api',
|
||||||
|
'insights': insights_data,
|
||||||
|
'message': 'Fetched fresh data from GSC API'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching fresh GSC data: {e}", exc_info=True)
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"API fetch failed: {str(e)}",
|
||||||
|
result_data={'error': str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def calculate_next_execution(
|
||||||
|
self,
|
||||||
|
task: PlatformInsightsTask,
|
||||||
|
frequency: str,
|
||||||
|
last_execution: Optional[datetime] = None
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next execution time based on frequency.
|
||||||
|
|
||||||
|
For platform insights, frequency is always 'Weekly' (7 days).
|
||||||
|
"""
|
||||||
|
if last_execution is None:
|
||||||
|
last_execution = datetime.utcnow()
|
||||||
|
|
||||||
|
if frequency == 'Weekly':
|
||||||
|
return last_execution + timedelta(days=7)
|
||||||
|
elif frequency == 'Daily':
|
||||||
|
return last_execution + timedelta(days=1)
|
||||||
|
else:
|
||||||
|
# Default to weekly
|
||||||
|
return last_execution + timedelta(days=7)
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
|
|||||||
- GSC: gsc_credentials table (via GSCService)
|
- GSC: gsc_credentials table (via GSCService)
|
||||||
- Bing: bing_oauth_tokens table (via BingOAuthService)
|
- Bing: bing_oauth_tokens table (via BingOAuthService)
|
||||||
- WordPress: wordpress_oauth_tokens table (via WordPressOAuthService)
|
- WordPress: wordpress_oauth_tokens table (via WordPressOAuthService)
|
||||||
- Wix: Currently in frontend sessionStorage (backend storage TODO)
|
- Wix: wix_oauth_tokens table (via WixOAuthService)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task: OAuthTokenMonitoringTask instance
|
task: OAuthTokenMonitoringTask instance
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""
|
||||||
|
Website Analysis Task Executor
|
||||||
|
Handles execution of website analysis tasks for user and competitor websites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from functools import partial
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from ..core.executor_interface import TaskExecutor, TaskExecutionResult
|
||||||
|
from ..core.exception_handler import TaskExecutionError, DatabaseError, SchedulerExceptionHandler
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask, WebsiteAnalysisExecutionLog
|
||||||
|
from models.onboarding import CompetitorAnalysis, OnboardingSession
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
# Import website analysis services
|
||||||
|
from services.component_logic.web_crawler_logic import WebCrawlerLogic
|
||||||
|
from services.component_logic.style_detection_logic import StyleDetectionLogic
|
||||||
|
from services.website_analysis_service import WebsiteAnalysisService
|
||||||
|
|
||||||
|
logger = get_service_logger("website_analysis_executor")
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteAnalysisExecutor(TaskExecutor):
|
||||||
|
"""
|
||||||
|
Executor for website analysis tasks.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Analyzing user's website (updates existing WebsiteAnalysis record)
|
||||||
|
- Analyzing competitor websites (stores in CompetitorAnalysis table)
|
||||||
|
- Logging results and updating task status
|
||||||
|
- Scheduling next execution based on frequency_days
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logger
|
||||||
|
self.exception_handler = SchedulerExceptionHandler()
|
||||||
|
self.crawler_logic = WebCrawlerLogic()
|
||||||
|
self.style_logic = StyleDetectionLogic()
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
self,
|
||||||
|
task: WebsiteAnalysisTask,
|
||||||
|
db: Session
|
||||||
|
) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute a website analysis task.
|
||||||
|
|
||||||
|
This performs complete website analysis using the same logic as
|
||||||
|
/api/onboarding/style-detection/complete endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: WebsiteAnalysisTask instance
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskExecutionResult
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
user_id = task.user_id
|
||||||
|
website_url = task.website_url
|
||||||
|
task_type = task.task_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(
|
||||||
|
f"Executing website analysis: task_id={task.id} | "
|
||||||
|
f"user_id={user_id} | url={website_url} | type={task_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create execution log
|
||||||
|
execution_log = WebsiteAnalysisExecutionLog(
|
||||||
|
task_id=task.id,
|
||||||
|
execution_date=datetime.utcnow(),
|
||||||
|
status='running'
|
||||||
|
)
|
||||||
|
db.add(execution_log)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Perform website analysis
|
||||||
|
result = await self._perform_website_analysis(
|
||||||
|
website_url=website_url,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type=task_type,
|
||||||
|
task=task,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update execution log
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
execution_log.status = 'success' if result.success else 'failed'
|
||||||
|
execution_log.result_data = result.result_data
|
||||||
|
execution_log.error_message = result.error_message
|
||||||
|
execution_log.execution_time_ms = execution_time_ms
|
||||||
|
|
||||||
|
# Update task based on result
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
task.last_success = datetime.utcnow()
|
||||||
|
task.status = 'active'
|
||||||
|
task.failure_reason = None
|
||||||
|
# Schedule next check based on frequency_days
|
||||||
|
task.next_check = self.calculate_next_execution(
|
||||||
|
task=task,
|
||||||
|
frequency='Custom',
|
||||||
|
last_execution=task.last_check,
|
||||||
|
custom_days=task.frequency_days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit all changes to database
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Website analysis completed successfully for task {task.id}. "
|
||||||
|
f"Next check scheduled for {task.next_check}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = result.error_message
|
||||||
|
task.status = 'failed'
|
||||||
|
# Do NOT update next_check - wait for manual retry
|
||||||
|
|
||||||
|
# Commit all changes to database
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
f"Website analysis failed for task {task.id}. "
|
||||||
|
f"Error: {result.error_message}. Waiting for manual retry."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Set database session for exception handler
|
||||||
|
self.exception_handler.db = db
|
||||||
|
|
||||||
|
# Create structured error
|
||||||
|
error = TaskExecutionError(
|
||||||
|
message=f"Error executing website analysis task {task.id}: {str(e)}",
|
||||||
|
user_id=user_id,
|
||||||
|
task_id=task.id,
|
||||||
|
task_type="website_analysis",
|
||||||
|
execution_time_ms=execution_time_ms,
|
||||||
|
context={
|
||||||
|
"website_url": website_url,
|
||||||
|
"task_type": task_type,
|
||||||
|
"user_id": user_id
|
||||||
|
},
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle exception with structured logging
|
||||||
|
self.exception_handler.handle_exception(error)
|
||||||
|
|
||||||
|
# Update execution log with error
|
||||||
|
try:
|
||||||
|
execution_log = WebsiteAnalysisExecutionLog(
|
||||||
|
task_id=task.id,
|
||||||
|
execution_date=datetime.utcnow(),
|
||||||
|
status='failed',
|
||||||
|
error_message=str(e),
|
||||||
|
execution_time_ms=execution_time_ms,
|
||||||
|
result_data={
|
||||||
|
"error_type": error.error_type.value,
|
||||||
|
"severity": error.severity.value,
|
||||||
|
"context": error.context
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(execution_log)
|
||||||
|
|
||||||
|
task.last_failure = datetime.utcnow()
|
||||||
|
task.failure_reason = str(e)
|
||||||
|
task.status = 'failed'
|
||||||
|
task.last_check = datetime.utcnow()
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
# Do NOT update next_check - wait for manual retry
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
db_error = DatabaseError(
|
||||||
|
message=f"Error saving execution log: {str(commit_error)}",
|
||||||
|
user_id=user_id,
|
||||||
|
task_id=task.id,
|
||||||
|
original_error=commit_error
|
||||||
|
)
|
||||||
|
self.exception_handler.handle_exception(db_error)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e),
|
||||||
|
execution_time_ms=execution_time_ms,
|
||||||
|
retryable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _perform_website_analysis(
|
||||||
|
self,
|
||||||
|
website_url: str,
|
||||||
|
user_id: str,
|
||||||
|
task_type: str,
|
||||||
|
task: WebsiteAnalysisTask,
|
||||||
|
db: Session
|
||||||
|
) -> TaskExecutionResult:
|
||||||
|
"""
|
||||||
|
Perform website analysis using existing service logic.
|
||||||
|
|
||||||
|
Reuses the same logic as /api/onboarding/style-detection/complete.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Step 1: Crawl website content
|
||||||
|
self.logger.info(f"Crawling website: {website_url}")
|
||||||
|
crawl_result = await self.crawler_logic.crawl_website(website_url)
|
||||||
|
|
||||||
|
if not crawl_result.get('success'):
|
||||||
|
error_msg = crawl_result.get('error', 'Crawling failed')
|
||||||
|
self.logger.error(f"Crawling failed for {website_url}: {error_msg}")
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"Crawling failed: {error_msg}",
|
||||||
|
result_data={'crawl_result': crawl_result},
|
||||||
|
retryable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Run style analysis and patterns analysis in parallel
|
||||||
|
self.logger.info(f"Running style analysis for {website_url}")
|
||||||
|
|
||||||
|
async def run_style_analysis():
|
||||||
|
"""Run style analysis in executor"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
partial(self.style_logic.analyze_content_style, crawl_result['content'])
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_patterns_analysis():
|
||||||
|
"""Run patterns analysis in executor"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
partial(self.style_logic.analyze_style_patterns, crawl_result['content'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute style and patterns analysis in parallel
|
||||||
|
style_analysis, patterns_result = await asyncio.gather(
|
||||||
|
run_style_analysis(),
|
||||||
|
run_patterns_analysis(),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for exceptions
|
||||||
|
if isinstance(style_analysis, Exception):
|
||||||
|
self.logger.error(f"Style analysis exception: {style_analysis}")
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=f"Style analysis failed: {str(style_analysis)}",
|
||||||
|
retryable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(patterns_result, Exception):
|
||||||
|
self.logger.warning(f"Patterns analysis exception: {patterns_result}")
|
||||||
|
patterns_result = None
|
||||||
|
|
||||||
|
# Step 3: Generate style guidelines
|
||||||
|
style_guidelines = None
|
||||||
|
if style_analysis and style_analysis.get('success'):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
guidelines_result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
partial(self.style_logic.generate_style_guidelines, style_analysis.get('analysis', {}))
|
||||||
|
)
|
||||||
|
if guidelines_result and guidelines_result.get('success'):
|
||||||
|
style_guidelines = guidelines_result.get('guidelines')
|
||||||
|
|
||||||
|
# Prepare analysis data
|
||||||
|
analysis_data = {
|
||||||
|
'crawl_result': crawl_result,
|
||||||
|
'style_analysis': style_analysis.get('analysis') if style_analysis and style_analysis.get('success') else None,
|
||||||
|
'style_patterns': patterns_result if patterns_result and not isinstance(patterns_result, Exception) else None,
|
||||||
|
'style_guidelines': style_guidelines,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Store results based on task type
|
||||||
|
if task_type == 'user_website':
|
||||||
|
# Update existing WebsiteAnalysis record
|
||||||
|
await self._update_user_website_analysis(
|
||||||
|
user_id=user_id,
|
||||||
|
website_url=website_url,
|
||||||
|
analysis_data=analysis_data,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
elif task_type == 'competitor':
|
||||||
|
# Store in CompetitorAnalysis table
|
||||||
|
await self._store_competitor_analysis(
|
||||||
|
user_id=user_id,
|
||||||
|
competitor_url=website_url,
|
||||||
|
competitor_id=task.competitor_id,
|
||||||
|
analysis_data=analysis_data,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(f"Website analysis completed successfully for {website_url}")
|
||||||
|
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=True,
|
||||||
|
result_data=analysis_data,
|
||||||
|
retryable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error performing website analysis: {e}", exc_info=True)
|
||||||
|
return TaskExecutionResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e),
|
||||||
|
retryable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_user_website_analysis(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
website_url: str,
|
||||||
|
analysis_data: Dict[str, Any],
|
||||||
|
db: Session
|
||||||
|
):
|
||||||
|
"""Update existing WebsiteAnalysis record for user's website."""
|
||||||
|
try:
|
||||||
|
# Convert Clerk user ID to integer (same as component_logic.py)
|
||||||
|
# Use the same conversion logic as the website analysis API
|
||||||
|
import hashlib
|
||||||
|
user_id_int = int(hashlib.sha256(user_id.encode()).hexdigest()[:15], 16)
|
||||||
|
|
||||||
|
# Use WebsiteAnalysisService to update
|
||||||
|
analysis_service = WebsiteAnalysisService(db)
|
||||||
|
|
||||||
|
# Prepare data in format expected by save_analysis
|
||||||
|
response_data = {
|
||||||
|
'crawl_result': analysis_data.get('crawl_result'),
|
||||||
|
'style_analysis': analysis_data.get('style_analysis'),
|
||||||
|
'style_patterns': analysis_data.get('style_patterns'),
|
||||||
|
'style_guidelines': analysis_data.get('style_guidelines'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save/update analysis
|
||||||
|
analysis_id = analysis_service.save_analysis(
|
||||||
|
session_id=user_id_int,
|
||||||
|
website_url=website_url,
|
||||||
|
analysis_data=response_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if analysis_id:
|
||||||
|
self.logger.info(f"Updated user website analysis for {website_url} (analysis_id: {analysis_id})")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Failed to update user website analysis for {website_url}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error updating user website analysis: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _store_competitor_analysis(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
competitor_url: str,
|
||||||
|
competitor_id: Optional[str],
|
||||||
|
analysis_data: Dict[str, Any],
|
||||||
|
db: Session
|
||||||
|
):
|
||||||
|
"""Store competitor analysis in CompetitorAnalysis table."""
|
||||||
|
try:
|
||||||
|
# Get onboarding session for user
|
||||||
|
session = db.query(OnboardingSession).filter(
|
||||||
|
OnboardingSession.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise ValueError(f"No onboarding session found for user {user_id}")
|
||||||
|
|
||||||
|
# Extract domain from URL
|
||||||
|
parsed_url = urlparse(competitor_url)
|
||||||
|
competitor_domain = parsed_url.netloc or competitor_id
|
||||||
|
|
||||||
|
# Check if analysis already exists for this competitor
|
||||||
|
existing = db.query(CompetitorAnalysis).filter(
|
||||||
|
CompetitorAnalysis.session_id == session.id,
|
||||||
|
CompetitorAnalysis.competitor_url == competitor_url
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing analysis
|
||||||
|
existing.analysis_data = analysis_data
|
||||||
|
existing.analysis_date = datetime.utcnow()
|
||||||
|
existing.status = 'completed'
|
||||||
|
existing.error_message = None
|
||||||
|
existing.warning_message = None
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
self.logger.info(f"Updated competitor analysis for {competitor_url}")
|
||||||
|
else:
|
||||||
|
# Create new analysis
|
||||||
|
competitor_analysis = CompetitorAnalysis(
|
||||||
|
session_id=session.id,
|
||||||
|
competitor_url=competitor_url,
|
||||||
|
competitor_domain=competitor_domain,
|
||||||
|
analysis_data=analysis_data,
|
||||||
|
status='completed',
|
||||||
|
analysis_date=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(competitor_analysis)
|
||||||
|
self.logger.info(f"Created new competitor analysis for {competitor_url}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
self.logger.error(f"Error storing competitor analysis: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def calculate_next_execution(
|
||||||
|
self,
|
||||||
|
task: WebsiteAnalysisTask,
|
||||||
|
frequency: str,
|
||||||
|
last_execution: Optional[datetime] = None,
|
||||||
|
custom_days: Optional[int] = None
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next execution time based on frequency or custom days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: WebsiteAnalysisTask instance
|
||||||
|
frequency: Frequency string ('Custom' for website analysis)
|
||||||
|
last_execution: Last execution datetime (defaults to task.last_check or now)
|
||||||
|
custom_days: Custom number of days (from task.frequency_days)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next execution datetime
|
||||||
|
"""
|
||||||
|
if last_execution is None:
|
||||||
|
last_execution = task.last_check if task.last_check else datetime.utcnow()
|
||||||
|
|
||||||
|
# Use custom_days if provided, otherwise use task.frequency_days
|
||||||
|
days = custom_days if custom_days is not None else task.frequency_days
|
||||||
|
|
||||||
|
if frequency == 'Custom' and days:
|
||||||
|
return last_execution + timedelta(days=days)
|
||||||
|
else:
|
||||||
|
# Default to task's frequency_days
|
||||||
|
self.logger.warning(
|
||||||
|
f"Unknown frequency '{frequency}' for website analysis task {task.id}. "
|
||||||
|
f"Using frequency_days={task.frequency_days}."
|
||||||
|
)
|
||||||
|
return last_execution + timedelta(days=task.frequency_days)
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Platform Insights Task Loader
|
||||||
|
Functions to load due platform insights tasks from database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from models.platform_insights_monitoring_models import PlatformInsightsTask
|
||||||
|
|
||||||
|
|
||||||
|
def load_due_platform_insights_tasks(
|
||||||
|
db: Session,
|
||||||
|
user_id: Optional[Union[str, int]] = None,
|
||||||
|
platform: Optional[str] = None
|
||||||
|
) -> List[PlatformInsightsTask]:
|
||||||
|
"""
|
||||||
|
Load all platform insights tasks that are due for execution.
|
||||||
|
|
||||||
|
Criteria:
|
||||||
|
- status == 'active' (only check active tasks)
|
||||||
|
- next_check <= now (or is None for first execution)
|
||||||
|
- Optional: user_id filter for specific user
|
||||||
|
- Optional: platform filter ('gsc' or 'bing')
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user_id: Optional user ID (Clerk string) to filter tasks
|
||||||
|
platform: Optional platform filter ('gsc' or 'bing')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of due PlatformInsightsTask instances
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Build query for due tasks
|
||||||
|
query = db.query(PlatformInsightsTask).filter(
|
||||||
|
and_(
|
||||||
|
PlatformInsightsTask.status == 'active',
|
||||||
|
or_(
|
||||||
|
PlatformInsightsTask.next_check <= now,
|
||||||
|
PlatformInsightsTask.next_check.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply user filter if provided
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.filter(PlatformInsightsTask.user_id == str(user_id))
|
||||||
|
|
||||||
|
# Apply platform filter if provided
|
||||||
|
if platform is not None:
|
||||||
|
query = query.filter(PlatformInsightsTask.platform == platform)
|
||||||
|
|
||||||
|
tasks = query.all()
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Website Analysis Task Loader
|
||||||
|
Functions to load due website analysis tasks from database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
|
||||||
|
|
||||||
|
|
||||||
|
def load_due_website_analysis_tasks(
|
||||||
|
db: Session,
|
||||||
|
user_id: Optional[Union[str, int]] = None
|
||||||
|
) -> List[WebsiteAnalysisTask]:
|
||||||
|
"""
|
||||||
|
Load all website analysis tasks that are due for execution.
|
||||||
|
|
||||||
|
Criteria:
|
||||||
|
- status == 'active' (only check active tasks)
|
||||||
|
- next_check <= now (or is None for first execution)
|
||||||
|
- Optional: user_id filter for specific user (for user isolation)
|
||||||
|
|
||||||
|
User isolation is enforced through filtering by user_id when provided.
|
||||||
|
If no user_id is provided, loads tasks for all users (for system-wide monitoring).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user_id: Optional user ID (Clerk string) to filter tasks (if None, loads all users' tasks)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of due WebsiteAnalysisTask instances
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Build query for due tasks
|
||||||
|
query = db.query(WebsiteAnalysisTask).filter(
|
||||||
|
and_(
|
||||||
|
WebsiteAnalysisTask.status == 'active',
|
||||||
|
or_(
|
||||||
|
WebsiteAnalysisTask.next_check <= now,
|
||||||
|
WebsiteAnalysisTask.next_check.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply user filter if provided (for user isolation)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.filter(WebsiteAnalysisTask.user_id == str(user_id))
|
||||||
|
|
||||||
|
return query.all()
|
||||||
|
|
||||||
@@ -144,6 +144,11 @@ def get_exa_key(user_id: Optional[str] = None) -> Optional[str]:
|
|||||||
return UserAPIKeyContext.get_user_key(user_id, 'exa')
|
return UserAPIKeyContext.get_user_key(user_id, 'exa')
|
||||||
|
|
||||||
|
|
||||||
|
def get_tavily_key(user_id: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Get Tavily API key for user."""
|
||||||
|
return UserAPIKeyContext.get_user_key(user_id, 'tavily')
|
||||||
|
|
||||||
|
|
||||||
def get_copilotkit_key(user_id: Optional[str] = None) -> Optional[str]:
|
def get_copilotkit_key(user_id: Optional[str] = None) -> Optional[str]:
|
||||||
"""Get CopilotKit API key for user."""
|
"""Get CopilotKit API key for user."""
|
||||||
return UserAPIKeyContext.get_user_key(user_id, 'copilotkit')
|
return UserAPIKeyContext.get_user_key(user_id, 'copilotkit')
|
||||||
|
|||||||
369
backend/services/website_analysis_monitoring_service.py
Normal file
369
backend/services/website_analysis_monitoring_service.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
Website Analysis Monitoring Service
|
||||||
|
Creates and manages website analysis monitoring tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
|
||||||
|
from models.onboarding import OnboardingSession
|
||||||
|
from services.onboarding.database_service import OnboardingDatabaseService
|
||||||
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
|
logger = get_service_logger("website_analysis_monitoring")
|
||||||
|
|
||||||
|
|
||||||
|
def clerk_user_id_to_int(user_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Convert Clerk user ID to consistent integer for database session_id.
|
||||||
|
Uses SHA256 hashing for deterministic, consistent results.
|
||||||
|
This MUST match the pattern used in component_logic.py for onboarding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Clerk user ID (e.g., 'user_33Gz1FPI86VDXhRY8QN4ragRFGN')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Deterministic integer derived from user ID
|
||||||
|
"""
|
||||||
|
user_id_hash = hashlib.sha256(user_id.encode()).hexdigest()
|
||||||
|
return int(user_id_hash[:8], 16) % 2147483647
|
||||||
|
|
||||||
|
|
||||||
|
def create_website_analysis_tasks(user_id: str, db: Session) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create website analysis tasks for user's website and all competitors.
|
||||||
|
|
||||||
|
This should be called after onboarding completion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Clerk user ID (string)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and task details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"[Website Analysis Tasks] Creating tasks for user: {user_id}")
|
||||||
|
|
||||||
|
# Get user's website URL from onboarding
|
||||||
|
onboarding_service = OnboardingDatabaseService(db=db)
|
||||||
|
website_analysis = onboarding_service.get_website_analysis(user_id, db)
|
||||||
|
|
||||||
|
if not website_analysis:
|
||||||
|
logger.warning(f"[Website Analysis Tasks] No website analysis found for user {user_id}")
|
||||||
|
# Try direct query using hash-based session_id (must match onboarding pattern)
|
||||||
|
try:
|
||||||
|
from models.onboarding import WebsiteAnalysis
|
||||||
|
session_id_int = clerk_user_id_to_int(user_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Website Analysis Tasks] Querying WebsiteAnalysis with hash-based session_id: {session_id_int}"
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis = db.query(WebsiteAnalysis).filter(
|
||||||
|
WebsiteAnalysis.session_id == session_id_int
|
||||||
|
).order_by(WebsiteAnalysis.created_at.desc()).first()
|
||||||
|
|
||||||
|
if analysis:
|
||||||
|
logger.info(f"[Website Analysis Tasks] ✅ Found analysis via hash-based query: {analysis.website_url}")
|
||||||
|
website_analysis = analysis.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Website Analysis Tasks] Direct query fallback failed: {e}")
|
||||||
|
|
||||||
|
if not website_analysis:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'No website analysis found. Complete onboarding first.'
|
||||||
|
}
|
||||||
|
|
||||||
|
website_url = website_analysis.get('website_url')
|
||||||
|
|
||||||
|
# Log the actual value for debugging (always log, not just debug level)
|
||||||
|
logger.info(
|
||||||
|
f"[Website Analysis Tasks] website_url from dict: {repr(website_url)} "
|
||||||
|
f"(type: {type(website_url).__name__}, truthy: {bool(website_url)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if website_url is None, empty string, or whitespace
|
||||||
|
if not website_url or (isinstance(website_url, str) and not website_url.strip()):
|
||||||
|
# Log what we actually got for debugging
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Tasks] No website URL found for user {user_id}. "
|
||||||
|
f"Analysis keys: {list(website_analysis.keys()) if website_analysis else 'None'}, "
|
||||||
|
f"website_url value: {repr(website_url)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try direct access to the model using hash-based session_id
|
||||||
|
# This MUST use the same hash function as onboarding (clerk_user_id_to_int)
|
||||||
|
try:
|
||||||
|
from models.onboarding import WebsiteAnalysis
|
||||||
|
session_id_int = clerk_user_id_to_int(user_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Website Analysis Tasks] Querying WebsiteAnalysis with hash-based session_id: {session_id_int} "
|
||||||
|
f"for user {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis = db.query(WebsiteAnalysis).filter(
|
||||||
|
WebsiteAnalysis.session_id == session_id_int
|
||||||
|
).order_by(WebsiteAnalysis.created_at.desc()).first()
|
||||||
|
|
||||||
|
if analysis:
|
||||||
|
logger.info(
|
||||||
|
f"[Website Analysis Tasks] Direct model access - "
|
||||||
|
f"website_url: {repr(analysis.website_url)}, "
|
||||||
|
f"type: {type(analysis.website_url).__name__ if analysis.website_url else 'None'}, "
|
||||||
|
f"id: {analysis.id}, session_id: {analysis.session_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if analysis.website_url:
|
||||||
|
website_url = analysis.website_url
|
||||||
|
logger.info(f"[Website Analysis Tasks] ✅ Retrieved website_url via hash-based query: {website_url}")
|
||||||
|
else:
|
||||||
|
# Try to extract URL from crawl_result if website_url is NULL
|
||||||
|
if analysis.crawl_result and isinstance(analysis.crawl_result, dict):
|
||||||
|
# Check multiple possible locations for URL
|
||||||
|
crawl_url = (
|
||||||
|
analysis.crawl_result.get('url') or
|
||||||
|
analysis.crawl_result.get('website_url') or
|
||||||
|
(analysis.crawl_result.get('content', {}).get('domain_info', {}).get('domain') if isinstance(analysis.crawl_result.get('content'), dict) else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# If still not found, check if crawl_result has nested structure
|
||||||
|
if not crawl_url and 'content' in analysis.crawl_result:
|
||||||
|
content = analysis.crawl_result.get('content', {})
|
||||||
|
if isinstance(content, dict):
|
||||||
|
# Check domain_info for domain
|
||||||
|
domain_info = content.get('domain_info', {})
|
||||||
|
if isinstance(domain_info, dict):
|
||||||
|
crawl_url = domain_info.get('domain') or domain_info.get('url')
|
||||||
|
|
||||||
|
if crawl_url:
|
||||||
|
# Ensure it's a full URL (add https:// if missing)
|
||||||
|
if crawl_url and not crawl_url.startswith(('http://', 'https://')):
|
||||||
|
crawl_url = f"https://{crawl_url}"
|
||||||
|
logger.info(f"[Website Analysis Tasks] ✅ Extracted website_url from crawl_result: {crawl_url}")
|
||||||
|
website_url = crawl_url
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Tasks] Cannot extract URL from crawl_result. "
|
||||||
|
f"crawl_result keys: {list(analysis.crawl_result.keys()) if isinstance(analysis.crawl_result, dict) else 'not a dict'}, "
|
||||||
|
f"Analysis ID: {analysis.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Tasks] website_url is NULL and crawl_result is empty or invalid. "
|
||||||
|
f"Analysis ID: {analysis.id}, Status: {analysis.status}, "
|
||||||
|
f"crawl_result type: {type(analysis.crawl_result).__name__ if analysis.crawl_result else 'None'}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[Website Analysis Tasks] No WebsiteAnalysis record found for "
|
||||||
|
f"hash-based session_id {session_id_int} (user {user_id})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Website Analysis Tasks] Hash-based query fallback failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
if not website_url:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'No website URL found in onboarding data. Please complete step 2 (Website Analysis) in onboarding.'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"[Website Analysis Tasks] User website URL: {website_url}")
|
||||||
|
|
||||||
|
tasks_created = []
|
||||||
|
|
||||||
|
# 1. Create task for user's website (optional recurring every 30 days)
|
||||||
|
user_task = _create_or_update_task(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
website_url=website_url,
|
||||||
|
task_type='user_website',
|
||||||
|
frequency_days=30 # Optional: recurring every 30 days
|
||||||
|
)
|
||||||
|
if user_task:
|
||||||
|
tasks_created.append(user_task)
|
||||||
|
logger.info(f"Created user website analysis task for {website_url}")
|
||||||
|
|
||||||
|
# 2. Get competitors from onboarding
|
||||||
|
competitors = _get_competitors_from_onboarding(user_id, db)
|
||||||
|
logger.info(
|
||||||
|
f"[Website Analysis Tasks] Found {len(competitors)} competitors for user {user_id}. "
|
||||||
|
f"Competitors: {[c.get('url') or c.get('website_url') or c.get('domain') for c in competitors]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Create task for each competitor
|
||||||
|
for competitor in competitors:
|
||||||
|
competitor_url = competitor.get('url') or competitor.get('website_url')
|
||||||
|
if not competitor_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract competitor identifier
|
||||||
|
competitor_id = competitor.get('domain') or competitor.get('id') or _extract_domain(competitor_url)
|
||||||
|
|
||||||
|
competitor_task = _create_or_update_task(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
website_url=competitor_url,
|
||||||
|
task_type='competitor',
|
||||||
|
competitor_id=competitor_id,
|
||||||
|
frequency_days=10 # Recurring every 10 days
|
||||||
|
)
|
||||||
|
if competitor_task:
|
||||||
|
tasks_created.append(competitor_task)
|
||||||
|
logger.info(f"Created competitor analysis task for {competitor_url}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Created {len(tasks_created)} website analysis tasks for user {user_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'tasks_created': len(tasks_created),
|
||||||
|
'tasks': [{
|
||||||
|
'id': t.id,
|
||||||
|
'url': t.website_url,
|
||||||
|
'type': t.task_type,
|
||||||
|
'next_check': t.next_check.isoformat() if t.next_check else None
|
||||||
|
} for t in tasks_created]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating website analysis tasks for user {user_id}: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_or_update_task(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
website_url: str,
|
||||||
|
task_type: str,
|
||||||
|
competitor_id: Optional[str] = None,
|
||||||
|
frequency_days: int = 10
|
||||||
|
) -> Optional[WebsiteAnalysisTask]:
|
||||||
|
"""Create or update a website analysis task."""
|
||||||
|
try:
|
||||||
|
# Check if task already exists
|
||||||
|
existing = db.query(WebsiteAnalysisTask).filter(
|
||||||
|
WebsiteAnalysisTask.user_id == user_id,
|
||||||
|
WebsiteAnalysisTask.website_url == website_url,
|
||||||
|
WebsiteAnalysisTask.task_type == task_type
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing task
|
||||||
|
existing.status = 'active'
|
||||||
|
existing.frequency_days = frequency_days
|
||||||
|
existing.next_check = datetime.utcnow() + timedelta(days=frequency_days)
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
if competitor_id:
|
||||||
|
existing.competitor_id = competitor_id
|
||||||
|
logger.info(f"Updated existing website analysis task {existing.id}")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Create new task
|
||||||
|
task = WebsiteAnalysisTask(
|
||||||
|
user_id=user_id,
|
||||||
|
website_url=website_url,
|
||||||
|
task_type=task_type,
|
||||||
|
competitor_id=competitor_id,
|
||||||
|
status='active',
|
||||||
|
frequency_days=frequency_days,
|
||||||
|
next_check=datetime.utcnow() + timedelta(days=frequency_days)
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.flush()
|
||||||
|
logger.info(f"Created new website analysis task {task.id} for {website_url}")
|
||||||
|
return task
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating/updating task: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_competitors_from_onboarding(user_id: str, db: Session) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get competitors from onboarding database.
|
||||||
|
|
||||||
|
Competitors are stored in onboarding_sessions.step_data['step3_research_data']['competitors']
|
||||||
|
or via Step3ResearchService.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get onboarding session
|
||||||
|
onboarding_service = OnboardingDatabaseService(db=db)
|
||||||
|
session = onboarding_service.get_session_by_user(user_id, db)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
logger.warning(f"No onboarding session found for user {user_id}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Try to get from step_data JSON column
|
||||||
|
competitors = []
|
||||||
|
|
||||||
|
# Method 1: Check if step_data column exists and has competitors
|
||||||
|
if hasattr(session, 'step_data') and session.step_data:
|
||||||
|
step_data = session.step_data if isinstance(session.step_data, dict) else {}
|
||||||
|
research_data = step_data.get('step3_research_data', {})
|
||||||
|
competitors = research_data.get('competitors', [])
|
||||||
|
logger.info(f"[Competitor Retrieval] Method 1 (step_data): found {len(competitors)} competitors")
|
||||||
|
|
||||||
|
# Method 2: If not found, try Step3ResearchService
|
||||||
|
if not competitors:
|
||||||
|
logger.info(f"[Competitor Retrieval] Attempting Step3ResearchService for user {user_id}, session_id: {session.id}")
|
||||||
|
try:
|
||||||
|
from api.onboarding_utils.step3_research_service import Step3ResearchService
|
||||||
|
import asyncio
|
||||||
|
step3_service = Step3ResearchService()
|
||||||
|
|
||||||
|
# Run async function - handle both new and existing event loops
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
research_data_result = loop.run_until_complete(
|
||||||
|
step3_service.get_research_data(str(session.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[Competitor Retrieval] Step3ResearchService result: {research_data_result.get('success')}")
|
||||||
|
|
||||||
|
if research_data_result.get('success'):
|
||||||
|
research_data = research_data_result.get('research_data', {})
|
||||||
|
step3_data = research_data.get('step3_research_data', {})
|
||||||
|
competitors = step3_data.get('competitors', [])
|
||||||
|
logger.info(f"[Competitor Retrieval] Retrieved {len(competitors)} competitors from Step3ResearchService")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Competitor Retrieval] Step3ResearchService returned error: {research_data_result.get('error')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Competitor Retrieval] Could not fetch competitors from Step3ResearchService: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Ensure competitors is a list
|
||||||
|
if not isinstance(competitors, list):
|
||||||
|
competitors = []
|
||||||
|
|
||||||
|
logger.info(f"Found {len(competitors)} competitors for user {user_id}")
|
||||||
|
return competitors
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting competitors from onboarding: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_domain(url: str) -> str:
|
||||||
|
"""Extract domain from URL."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return parsed.netloc or url
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Platform Insights Monitoring API Client
|
||||||
|
* Provides typed functions for fetching platform insights (GSC/Bing) monitoring data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
// TypeScript interfaces
|
||||||
|
export interface PlatformInsightsTask {
|
||||||
|
id: number;
|
||||||
|
platform: 'gsc' | 'bing';
|
||||||
|
site_url: string | null;
|
||||||
|
status: 'active' | 'failed' | 'paused';
|
||||||
|
last_check: string | null;
|
||||||
|
last_success: string | null;
|
||||||
|
last_failure: string | null;
|
||||||
|
failure_reason: string | null;
|
||||||
|
next_check: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInsightsStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
user_id: string;
|
||||||
|
gsc_tasks: PlatformInsightsTask[];
|
||||||
|
bing_tasks: PlatformInsightsTask[];
|
||||||
|
total_tasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInsightsExecutionLog {
|
||||||
|
id: number;
|
||||||
|
task_id: number;
|
||||||
|
execution_date: string;
|
||||||
|
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||||
|
result_data: any;
|
||||||
|
error_message: string | null;
|
||||||
|
execution_time_ms: number | null;
|
||||||
|
data_source: 'cached' | 'api' | 'onboarding' | 'storage' | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformInsightsLogsResponse {
|
||||||
|
success: boolean;
|
||||||
|
logs: PlatformInsightsExecutionLog[];
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform insights status for a user
|
||||||
|
*/
|
||||||
|
export const getPlatformInsightsStatus = async (
|
||||||
|
userId: string
|
||||||
|
): Promise<PlatformInsightsStatusResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/scheduler/platform-insights/status/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching platform insights status:', error);
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get execution logs for platform insights tasks
|
||||||
|
*/
|
||||||
|
export const getPlatformInsightsLogs = async (
|
||||||
|
userId: string,
|
||||||
|
limit: number = 10,
|
||||||
|
taskId?: number
|
||||||
|
): Promise<PlatformInsightsLogsResponse> => {
|
||||||
|
try {
|
||||||
|
const params: any = { limit };
|
||||||
|
if (taskId) {
|
||||||
|
params.task_id = taskId;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/api/scheduler/platform-insights/logs/${userId}`, {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching platform insights logs:', error);
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ import { apiClient } from './client';
|
|||||||
export interface ProviderAvailability {
|
export interface ProviderAvailability {
|
||||||
google_available: boolean;
|
google_available: boolean;
|
||||||
exa_available: boolean;
|
exa_available: boolean;
|
||||||
|
tavily_available: boolean;
|
||||||
gemini_key_status: 'configured' | 'missing';
|
gemini_key_status: 'configured' | 'missing';
|
||||||
exa_key_status: 'configured' | 'missing';
|
exa_key_status: 'configured' | 'missing';
|
||||||
|
tavily_key_status: 'configured' | 'missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonaDefaults {
|
export interface PersonaDefaults {
|
||||||
@@ -140,18 +142,85 @@ export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or refresh research persona
|
* Get or refresh research persona
|
||||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||||
*/
|
*/
|
||||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||||
try {
|
try {
|
||||||
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
||||||
const response = await apiClient.get(url);
|
const response = await apiClient.get(url);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[researchConfig] Error refreshing research persona:', error?.response?.status || error?.message);
|
console.error('[researchConfig] Error refreshing research persona:', error?.response?.status || error?.message);
|
||||||
// Preserve the original error so subscription errors can be detected
|
// Preserve the original error so subscription errors can be detected
|
||||||
// The apiClient interceptor should handle 429 errors, but we preserve the error structure
|
// The apiClient interceptor should handle 429 errors, but we preserve the error structure
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Competitor Analysis Response Interface
|
||||||
|
*/
|
||||||
|
export interface CompetitorAnalysisResponse {
|
||||||
|
success: boolean;
|
||||||
|
competitors?: Array<{
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
domain?: string;
|
||||||
|
description?: string;
|
||||||
|
similarity_score?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
social_media_accounts?: Record<string, string>;
|
||||||
|
social_media_citations?: Array<{
|
||||||
|
platform?: string;
|
||||||
|
account?: string;
|
||||||
|
url?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
research_summary?: {
|
||||||
|
total_competitors?: number;
|
||||||
|
industry_insights?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
analysis_timestamp?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get competitor analysis data from onboarding
|
||||||
|
*/
|
||||||
|
export const getCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||||
|
console.log('[getCompetitorAnalysis] ===== START: Fetching competitor analysis =====');
|
||||||
|
try {
|
||||||
|
console.log('[getCompetitorAnalysis] Making GET request to /api/research/competitor-analysis');
|
||||||
|
const response = await apiClient.get('/api/research/competitor-analysis');
|
||||||
|
console.log('[getCompetitorAnalysis] ✅ Response received:', {
|
||||||
|
success: response.data?.success,
|
||||||
|
competitorsCount: response.data?.competitors?.length || 0,
|
||||||
|
error: response.data?.error,
|
||||||
|
fullResponse: response.data
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
const statusCode = error?.response?.status;
|
||||||
|
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||||
|
|
||||||
|
console.error('[getCompetitorAnalysis] ❌ ERROR:', {
|
||||||
|
status: statusCode,
|
||||||
|
message: errorMessage,
|
||||||
|
fullError: error,
|
||||||
|
responseData: error?.response?.data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return error response instead of throwing
|
||||||
|
const errorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
console.log('[getCompetitorAnalysis] Returning error response:', errorResponse);
|
||||||
|
return errorResponse;
|
||||||
|
} finally {
|
||||||
|
console.log('[getCompetitorAnalysis] ===== END: Fetching competitor analysis =====');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ export interface SchedulerJob {
|
|||||||
job_store: string;
|
job_store: string;
|
||||||
user_job_store: string;
|
user_job_store: string;
|
||||||
function_name?: string | null;
|
function_name?: string | null;
|
||||||
platform?: string; // For OAuth token monitoring tasks
|
platform?: string; // For OAuth token monitoring tasks and platform insights
|
||||||
task_id?: number; // For OAuth token monitoring tasks
|
task_id?: number; // For OAuth token monitoring tasks, website analysis, and platform insights
|
||||||
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
||||||
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
||||||
|
task_type?: string; // For website analysis tasks ('user_website' or 'competitor')
|
||||||
|
task_category?: string; // 'website_analysis', 'platform_insights', 'oauth_token_monitoring'
|
||||||
|
website_url?: string | null; // For website analysis tasks
|
||||||
|
competitor_id?: number | null; // For competitor website analysis tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserIsolation {
|
export interface UserIsolation {
|
||||||
@@ -128,6 +132,11 @@ export interface SchedulerEventHistoryResponse {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
has_more: boolean;
|
has_more: boolean;
|
||||||
|
date_filter?: {
|
||||||
|
days: number;
|
||||||
|
cutoff_date: string;
|
||||||
|
showing_events_since: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,17 +208,19 @@ export const getSchedulerJobs = async (): Promise<SchedulerJobsResponse> => {
|
|||||||
/**
|
/**
|
||||||
* Get scheduler event history from database.
|
* Get scheduler event history from database.
|
||||||
*
|
*
|
||||||
* @param limit - Number of events to return (1-1000, default: 100)
|
* @param limit - Number of events to return (1-500, default: 5 for initial load, expand to 50 on hover)
|
||||||
* @param offset - Pagination offset (default: 0)
|
* @param offset - Pagination offset (default: 0)
|
||||||
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
||||||
|
* @param days - Number of days to look back (1-90, default: 7 days)
|
||||||
*/
|
*/
|
||||||
export const getSchedulerEventHistory = async (
|
export const getSchedulerEventHistory = async (
|
||||||
limit: number = 100,
|
limit: number = 5,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed'
|
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed',
|
||||||
|
days: number = 7
|
||||||
): Promise<SchedulerEventHistoryResponse> => {
|
): Promise<SchedulerEventHistoryResponse> => {
|
||||||
try {
|
try {
|
||||||
const params: any = { limit, offset };
|
const params: any = { limit, offset, days };
|
||||||
if (eventType) {
|
if (eventType) {
|
||||||
params.event_type = eventType;
|
params.event_type = eventType;
|
||||||
}
|
}
|
||||||
|
|||||||
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Website Analysis Monitoring API Client
|
||||||
|
* Provides typed functions for fetching website analysis monitoring data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
// TypeScript interfaces
|
||||||
|
export interface WebsiteAnalysisTask {
|
||||||
|
id: number;
|
||||||
|
website_url: string;
|
||||||
|
task_type: 'user_website' | 'competitor';
|
||||||
|
competitor_id: string | null;
|
||||||
|
status: 'active' | 'failed' | 'paused';
|
||||||
|
last_check: string | null;
|
||||||
|
last_success: string | null;
|
||||||
|
last_failure: string | null;
|
||||||
|
failure_reason: string | null;
|
||||||
|
next_check: string | null;
|
||||||
|
frequency_days: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteAnalysisStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
user_id: string;
|
||||||
|
user_website_tasks: WebsiteAnalysisTask[];
|
||||||
|
competitor_tasks: WebsiteAnalysisTask[];
|
||||||
|
total_tasks: number;
|
||||||
|
active_tasks: number;
|
||||||
|
failed_tasks: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteAnalysisExecutionLog {
|
||||||
|
id: number;
|
||||||
|
task_id: number;
|
||||||
|
website_url: string;
|
||||||
|
task_type: 'user_website' | 'competitor';
|
||||||
|
execution_date: string;
|
||||||
|
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||||
|
result_data: any;
|
||||||
|
error_message: string | null;
|
||||||
|
execution_time_ms: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteAnalysisLogsResponse {
|
||||||
|
logs: WebsiteAnalysisExecutionLog[];
|
||||||
|
total_count: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryWebsiteAnalysisResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
task: {
|
||||||
|
id: number;
|
||||||
|
website_url: string;
|
||||||
|
status: string;
|
||||||
|
next_check: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get website analysis status for a user
|
||||||
|
*/
|
||||||
|
export const getWebsiteAnalysisStatus = async (
|
||||||
|
userId: string
|
||||||
|
): Promise<WebsiteAnalysisStatusResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/scheduler/website-analysis/status/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching website analysis status:', error);
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get execution logs for website analysis tasks
|
||||||
|
*/
|
||||||
|
export const getWebsiteAnalysisLogs = async (
|
||||||
|
userId: string,
|
||||||
|
limit: number = 10,
|
||||||
|
offset: number = 0,
|
||||||
|
taskId?: number
|
||||||
|
): Promise<WebsiteAnalysisLogsResponse> => {
|
||||||
|
try {
|
||||||
|
const params: any = { limit, offset };
|
||||||
|
if (taskId) {
|
||||||
|
params.task_id = taskId;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/api/scheduler/website-analysis/logs/${userId}`, {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching website analysis logs:', error);
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually retry a failed website analysis task
|
||||||
|
*/
|
||||||
|
export const retryWebsiteAnalysis = async (
|
||||||
|
taskId: number
|
||||||
|
): Promise<RetryWebsiteAnalysisResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/api/scheduler/website-analysis/retry/${taskId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error retrying website analysis:', error);
|
||||||
|
throw new Error(error.response?.data?.detail || 'Failed to retry website analysis');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -160,6 +160,11 @@ export const BlogWriter: React.FC = () => {
|
|||||||
seoRecommendationsApplied
|
seoRecommendationsApplied
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update ref when navigateToPhase changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
navigateToPhaseRef.current = navigateToPhase;
|
||||||
|
}, [navigateToPhase]);
|
||||||
|
|
||||||
// Phase restoration logic
|
// Phase restoration logic
|
||||||
usePhaseRestoration({
|
usePhaseRestoration({
|
||||||
copilotKitAvailable,
|
copilotKitAvailable,
|
||||||
@@ -184,6 +189,9 @@ export const BlogWriter: React.FC = () => {
|
|||||||
sections
|
sections
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Store navigateToPhase in a ref for use in polling callbacks
|
||||||
|
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||||
|
|
||||||
// Polling hooks - extracted to useBlogWriterPolling
|
// Polling hooks - extracted to useBlogWriterPolling
|
||||||
const {
|
const {
|
||||||
researchPolling,
|
researchPolling,
|
||||||
@@ -198,6 +206,19 @@ export const BlogWriter: React.FC = () => {
|
|||||||
onOutlineComplete: handleOutlineComplete,
|
onOutlineComplete: handleOutlineComplete,
|
||||||
onOutlineError: handleOutlineError,
|
onOutlineError: handleOutlineError,
|
||||||
onSectionsUpdate: setSections,
|
onSectionsUpdate: setSections,
|
||||||
|
onContentConfirmed: () => {
|
||||||
|
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||||
|
setContentConfirmed(true);
|
||||||
|
},
|
||||||
|
navigateToPhase: (phase) => {
|
||||||
|
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||||
|
// Use ref to access navigateToPhase (defined later in component)
|
||||||
|
if (navigateToPhaseRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigateToPhaseRef.current?.(phase);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal visibility management - extracted to useModalVisibility
|
// Modal visibility management - extracted to useModalVisibility
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface UseBlogWriterPollingProps {
|
|||||||
onOutlineComplete: (outline: any) => void;
|
onOutlineComplete: (outline: any) => void;
|
||||||
onOutlineError: (error: any) => void;
|
onOutlineError: (error: any) => void;
|
||||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||||
|
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||||
|
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBlogWriterPolling = ({
|
export const useBlogWriterPolling = ({
|
||||||
@@ -19,6 +21,8 @@ export const useBlogWriterPolling = ({
|
|||||||
onOutlineComplete,
|
onOutlineComplete,
|
||||||
onOutlineError,
|
onOutlineError,
|
||||||
onSectionsUpdate,
|
onSectionsUpdate,
|
||||||
|
onContentConfirmed,
|
||||||
|
navigateToPhase,
|
||||||
}: UseBlogWriterPollingProps) => {
|
}: UseBlogWriterPollingProps) => {
|
||||||
// Research polling hook (for context awareness)
|
// Research polling hook (for context awareness)
|
||||||
const researchPolling = useResearchPolling({
|
const researchPolling = useResearchPolling({
|
||||||
@@ -47,6 +51,15 @@ export const useBlogWriterPolling = ({
|
|||||||
if (Object.keys(newSections).length > 0) {
|
if (Object.keys(newSections).length > 0) {
|
||||||
const sectionIds = Object.keys(newSections);
|
const sectionIds = Object.keys(newSections);
|
||||||
blogWriterCache.cacheContent(newSections, sectionIds);
|
blogWriterCache.cacheContent(newSections, sectionIds);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
import { useCopilotAction } from '@copilotkit/react-core';
|
||||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||||
import { useResearchPolling } from '../../hooks/usePolling';
|
import { useResearchPolling } from '../../hooks/usePolling';
|
||||||
@@ -60,6 +60,27 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close modal when research completes (status becomes 'completed' or polling stops with result)
|
||||||
|
useEffect(() => {
|
||||||
|
if (showProgressModal && (
|
||||||
|
polling.currentStatus === 'completed' ||
|
||||||
|
(!polling.isPolling && polling.result && polling.currentStatus !== 'failed')
|
||||||
|
)) {
|
||||||
|
console.info('[ResearchAction] Closing modal - research completed', {
|
||||||
|
status: polling.currentStatus,
|
||||||
|
isPolling: polling.isPolling,
|
||||||
|
hasResult: !!polling.result
|
||||||
|
});
|
||||||
|
// Small delay to show completion message before closing
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowProgressModal(false);
|
||||||
|
setCurrentTaskId(null);
|
||||||
|
setCurrentMessage('');
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [polling.currentStatus, polling.isPolling, polling.result, showProgressModal]);
|
||||||
|
|
||||||
useCopilotActionTyped({
|
useCopilotActionTyped({
|
||||||
name: 'showResearchForm',
|
name: 'showResearchForm',
|
||||||
description: 'Show keyword input form for blog research',
|
description: 'Show keyword input form for blog research',
|
||||||
@@ -235,12 +256,16 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
|||||||
<>
|
<>
|
||||||
{showProgressModal && (
|
{showProgressModal && (
|
||||||
<ResearchProgressModal
|
<ResearchProgressModal
|
||||||
open={showProgressModal}
|
open={showProgressModal && polling.currentStatus !== 'completed'}
|
||||||
title={"Research in progress"}
|
title={"Research in progress"}
|
||||||
status={polling.currentStatus}
|
status={polling.currentStatus}
|
||||||
messages={polling.progressMessages}
|
messages={polling.progressMessages}
|
||||||
error={polling.error}
|
error={polling.error}
|
||||||
onClose={() => setShowProgressModal(false)}
|
onClose={() => {
|
||||||
|
console.info('[ResearchAction] Modal closed manually');
|
||||||
|
setShowProgressModal(false);
|
||||||
|
setCurrentTaskId(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -190,8 +190,21 @@ export const useSuggestions = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No content yet, show generation option
|
// No content yet, but outline is confirmed - show content generation options
|
||||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
if (hasContent) {
|
||||||
|
// Content exists but not confirmed - show confirmation and SEO options
|
||||||
|
items.push({
|
||||||
|
title: 'Next: Run SEO Analysis',
|
||||||
|
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
title: '📊 Content Analysis',
|
||||||
|
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No content at all - show generation option (only if no content exists)
|
||||||
|
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
317
frontend/src/components/Research/OnboardingCompetitorModal.tsx
Normal file
317
frontend/src/components/Research/OnboardingCompetitorModal.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Close as CloseIcon,
|
||||||
|
Business as BusinessIcon,
|
||||||
|
Assessment as AssessmentIcon,
|
||||||
|
OpenInNew as OpenInNewIcon,
|
||||||
|
Link as LinkIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { CompetitorAnalysisResponse } from '../../api/researchConfig';
|
||||||
|
|
||||||
|
interface OnboardingCompetitorModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
data: CompetitorAnalysisResponse | null;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
error = null
|
||||||
|
}) => {
|
||||||
|
if (!data && !loading && !error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const competitors = data?.competitors || [];
|
||||||
|
const socialMediaAccounts = data?.social_media_accounts || {};
|
||||||
|
const researchSummary = data?.research_summary || {};
|
||||||
|
|
||||||
|
const avgScore = competitors.length > 0
|
||||||
|
? competitors.reduce((sum, c) => sum + (c.similarity_score || 0), 0) / competitors.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="lg"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||||
|
maxHeight: '90vh'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
pb: 2,
|
||||||
|
borderBottom: '2px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<BusinessIcon sx={{ fontSize: 32, color: '#0ea5e9' }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Competitive Analysis from Onboarding
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
|
||||||
|
{loading ? 'Loading...' : `${competitors.length} competitors analyzed`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button onClick={onClose} size="small" sx={{ minWidth: 'auto', p: 1 }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ py: 3, overflowY: 'auto' }}>
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography variant="body2" sx={{ ml: 2, color: '#64748b' }}>
|
||||||
|
Loading competitor data...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="body2">{error}</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{researchSummary.industry_insights && (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
icon={<AssessmentIcon />}
|
||||||
|
sx={{ mb: 3, bgcolor: '#e0f2fe', borderLeft: '4px solid #0ea5e9' }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
Market Insights
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#1e293b' }}>
|
||||||
|
{researchSummary.industry_insights}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Card sx={{
|
||||||
|
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||||
|
borderLeft: '4px solid #0ea5e9'
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600 }}>
|
||||||
|
Total Competitors
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: '#0c4a6e', fontWeight: 700 }}>
|
||||||
|
{competitors.length}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Card sx={{
|
||||||
|
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||||
|
borderLeft: '4px solid #22c55e'
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 600 }}>
|
||||||
|
Avg Similarity
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: '#166534', fontWeight: 700 }}>
|
||||||
|
{Math.round(avgScore * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<Card sx={{
|
||||||
|
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||||
|
borderLeft: '4px solid #f59e0b'
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 600 }}>
|
||||||
|
Social Accounts Found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: '#92400e', fontWeight: 700 }}>
|
||||||
|
{Object.keys(socialMediaAccounts).length}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{Object.keys(socialMediaAccounts).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Social Media Accounts
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||||
|
{Object.entries(socialMediaAccounts).map(([platform, url]) => (
|
||||||
|
<Chip
|
||||||
|
key={platform}
|
||||||
|
icon={<LinkIcon />}
|
||||||
|
label={`${platform}: ${url}`}
|
||||||
|
clickable
|
||||||
|
onClick={() => window.open(url, '_blank')}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: '#f1f5f9',
|
||||||
|
borderColor: '#cbd5e1'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{competitors.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Competitors ({competitors.length})
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{competitors.map((competitor, index) => (
|
||||||
|
<Grid item xs={12} md={6} key={index}>
|
||||||
|
<Card sx={{
|
||||||
|
height: '100%',
|
||||||
|
'&:hover': { boxShadow: 4 },
|
||||||
|
transition: 'box-shadow 0.3s'
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||||
|
<Avatar sx={{ width: 40, height: 40, bgcolor: '#0ea5e9' }}>
|
||||||
|
<BusinessIcon />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#0f172a',
|
||||||
|
mb: 0.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{competitor.name || competitor.domain || 'Unknown Competitor'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||||
|
{competitor.similarity_score !== undefined && (
|
||||||
|
<Chip
|
||||||
|
label={`Similarity: ${Math.round(competitor.similarity_score * 100)}%`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: competitor.similarity_score > 0.7
|
||||||
|
? '#dcfce7'
|
||||||
|
: competitor.similarity_score > 0.5
|
||||||
|
? '#fef3c7'
|
||||||
|
: '#fee2e2',
|
||||||
|
color: competitor.similarity_score > 0.7
|
||||||
|
? '#166534'
|
||||||
|
: competitor.similarity_score > 0.5
|
||||||
|
? '#92400e'
|
||||||
|
: '#991b1b',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{competitor.url && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
endIcon={<OpenInNewIcon />}
|
||||||
|
href={competitor.url}
|
||||||
|
target="_blank"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Visit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{competitor.description && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: '#64748b',
|
||||||
|
mb: 2,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{competitor.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{competitor.domain && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||||
|
{competitor.domain}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!loading && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
No competitor data available. Please complete onboarding step 3 to analyze competitors.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 4, py: 2, borderTop: '1px solid #e5e7eb' }}>
|
||||||
|
<Button onClick={onClose} variant="contained" sx={{ minWidth: 120 }}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useResearchWizard } from './hooks/useResearchWizard';
|
import { useResearchWizard } from './hooks/useResearchWizard';
|
||||||
import { useResearchExecution } from './hooks/useResearchExecution';
|
import { useResearchExecution } from './hooks/useResearchExecution';
|
||||||
import { ResearchInput } from './steps/ResearchInput';
|
import { ResearchInput } from './steps/ResearchInput';
|
||||||
@@ -6,6 +6,9 @@ import { StepProgress } from './steps/StepProgress';
|
|||||||
import { StepResults } from './steps/StepResults';
|
import { StepResults } from './steps/StepResults';
|
||||||
import { ResearchWizardProps } from './types/research.types';
|
import { ResearchWizardProps } from './types/research.types';
|
||||||
import { addResearchHistory } from '../../utils/researchHistory';
|
import { addResearchHistory } from '../../utils/researchHistory';
|
||||||
|
import { getResearchConfig, ProviderAvailability } from '../../api/researchConfig';
|
||||||
|
import { ProviderChips } from './steps/components/ProviderChips';
|
||||||
|
import { AdvancedChip } from './steps/components/AdvancedChip';
|
||||||
|
|
||||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||||
onComplete,
|
onComplete,
|
||||||
@@ -24,6 +27,30 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
initialConfig
|
initialConfig
|
||||||
);
|
);
|
||||||
const execution = useResearchExecution();
|
const execution = useResearchExecution();
|
||||||
|
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
|
||||||
|
const [advanced, setAdvanced] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Load provider availability on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProviderAvailability = async () => {
|
||||||
|
try {
|
||||||
|
const config = await getResearchConfig();
|
||||||
|
setProviderAvailability(config?.provider_availability || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ResearchWizard] Failed to load provider availability:', error);
|
||||||
|
// Set default availability on error
|
||||||
|
setProviderAvailability({
|
||||||
|
google_available: true,
|
||||||
|
exa_available: false,
|
||||||
|
tavily_available: false,
|
||||||
|
gemini_key_status: 'missing',
|
||||||
|
exa_key_status: 'missing',
|
||||||
|
tavily_key_status: 'missing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadProviderAvailability();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle results from execution
|
// Handle results from execution
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,13 +100,13 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
|
|
||||||
switch (wizard.state.currentStep) {
|
switch (wizard.state.currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return <ResearchInput {...stepProps} />;
|
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
|
||||||
case 2:
|
case 2:
|
||||||
return <StepProgress {...stepProps} execution={execution} />;
|
return <StepProgress {...stepProps} execution={execution} />;
|
||||||
case 3:
|
case 3:
|
||||||
return <StepResults {...stepProps} />;
|
return <StepResults {...stepProps} />;
|
||||||
default:
|
default:
|
||||||
return <ResearchInput {...stepProps} />;
|
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,31 +123,124 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
boxShadow: '0 4px 16px rgba(14, 165, 233, 0.1)',
|
boxShadow: '0 4px 16px rgba(14, 165, 233, 0.1)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
{/* Header with Compact Steps */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(56, 189, 248, 0.08) 100%)',
|
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(56, 189, 248, 0.08) 100%)',
|
||||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||||
padding: '20px 28px',
|
padding: '14px 24px',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '24px' }}>
|
||||||
<div>
|
{/* Title Section */}
|
||||||
<h1 style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: '1', flexWrap: 'wrap' }}>
|
||||||
margin: 0,
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
fontSize: '24px',
|
<h1 style={{
|
||||||
fontWeight: '700',
|
margin: 0,
|
||||||
color: '#0c4a6e',
|
fontSize: '20px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0c4a6e',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}>
|
||||||
|
Research Wizard
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Provider Status Chips */}
|
||||||
|
<ProviderChips providerAvailability={providerAvailability} advanced={advanced} />
|
||||||
|
|
||||||
|
{/* Advanced Chip */}
|
||||||
|
<AdvancedChip advanced={advanced} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Step Indicators */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginLeft: '8px',
|
||||||
}}>
|
}}>
|
||||||
Research Wizard
|
{[1, 2, 3].map((step, index) => {
|
||||||
</h1>
|
const isActive = step === wizard.state.currentStep;
|
||||||
<p style={{
|
const isCompleted = step < wizard.state.currentStep;
|
||||||
margin: '4px 0 0 0',
|
const isClickable = step <= wizard.state.currentStep;
|
||||||
fontSize: '13px',
|
|
||||||
color: '#0369a1',
|
return (
|
||||||
fontWeight: '400',
|
<React.Fragment key={step}>
|
||||||
}}>
|
{index > 0 && (
|
||||||
Phase {wizard.state.currentStep} of {wizard.maxSteps} • AI-Powered Intelligence
|
<div style={{
|
||||||
</p>
|
width: '20px',
|
||||||
|
height: '2px',
|
||||||
|
background: isCompleted || (step === wizard.state.currentStep)
|
||||||
|
? 'linear-gradient(90deg, #22c55e 0%, #16a34a 100%)'
|
||||||
|
: 'rgba(14, 165, 233, 0.2)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isClickable) {
|
||||||
|
wizard.updateState({ currentStep: step });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (isClickable) {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isActive
|
||||||
|
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
||||||
|
: isCompleted
|
||||||
|
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
||||||
|
: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
color: (isActive || isCompleted) ? 'white' : '#64748b',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
border: isActive ? '2px solid rgba(14, 165, 233, 0.3)' : '2px solid rgba(14, 165, 233, 0.1)',
|
||||||
|
boxShadow: isActive
|
||||||
|
? '0 2px 8px rgba(14, 165, 233, 0.25)'
|
||||||
|
: isCompleted
|
||||||
|
? '0 1px 4px rgba(34, 197, 94, 0.2)'
|
||||||
|
: 'none',
|
||||||
|
}}>
|
||||||
|
{isCompleted ? '✓' : step}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: (isActive || isCompleted) ? '#0c4a6e' : '#64748b',
|
||||||
|
fontWeight: isActive ? '600' : '400',
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{step === 1 && 'Configure'}
|
||||||
|
{step === 2 && 'Execute'}
|
||||||
|
{step === 3 && 'Analyze'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -128,13 +248,13 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
onCancel();
|
onCancel();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '6px 12px',
|
||||||
background: 'rgba(239, 68, 68, 0.1)',
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
color: '#dc2626',
|
color: '#dc2626',
|
||||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||||
borderRadius: '10px',
|
borderRadius: '8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
@@ -154,7 +274,7 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(14, 165, 233, 0.1)',
|
background: 'rgba(14, 165, 233, 0.1)',
|
||||||
height: '5px',
|
height: '3px',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
@@ -164,90 +284,11 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
|
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
|
||||||
transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
|
boxShadow: '0 0 6px rgba(14, 165, 233, 0.4)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Indicators */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
padding: '24px 40px',
|
|
||||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
|
||||||
background: 'rgba(14, 165, 233, 0.03)',
|
|
||||||
}}>
|
|
||||||
{[1, 2, 3].map(step => {
|
|
||||||
const isActive = step === wizard.state.currentStep;
|
|
||||||
const isCompleted = step < wizard.state.currentStep;
|
|
||||||
const isClickable = step <= wizard.state.currentStep;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={step}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
cursor: isClickable ? 'pointer' : 'default',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (isClickable) {
|
|
||||||
wizard.updateState({ currentStep: step });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (isClickable) {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '48px',
|
|
||||||
height: '48px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: isActive
|
|
||||||
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
|
||||||
: isCompleted
|
|
||||||
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
|
||||||
: 'rgba(14, 165, 233, 0.1)',
|
|
||||||
color: (isActive || isCompleted) ? 'white' : '#64748b',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: '18px',
|
|
||||||
marginBottom: '10px',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
border: isActive ? '2px solid rgba(14, 165, 233, 0.3)' : '2px solid rgba(14, 165, 233, 0.1)',
|
|
||||||
boxShadow: isActive
|
|
||||||
? '0 4px 16px rgba(14, 165, 233, 0.3)'
|
|
||||||
: isCompleted
|
|
||||||
? '0 2px 8px rgba(34, 197, 94, 0.2)'
|
|
||||||
: 'none',
|
|
||||||
}}>
|
|
||||||
{isCompleted ? '✓' : step}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: (isActive || isCompleted) ? '#0c4a6e' : '#64748b',
|
|
||||||
fontWeight: isActive ? '600' : '400',
|
|
||||||
letterSpacing: '0.01em',
|
|
||||||
}}>
|
|
||||||
{step === 1 && 'Configure'}
|
|
||||||
{step === 2 && 'Execute'}
|
|
||||||
{step === 3 && 'Analyze'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '20px' }}>
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface AdvancedChipProps {
|
||||||
|
advanced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdvancedChip: React.FC<AdvancedChipProps> = ({ advanced }) => {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Chip */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
background: advanced
|
||||||
|
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||||
|
border: `1px solid ${advanced ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.2)'}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: advanced ? '#10b981' : '#ef4444',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
cursor: 'default',
|
||||||
|
boxShadow: hovered
|
||||||
|
? '0 2px 8px rgba(0, 0, 0, 0.12)'
|
||||||
|
: '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||||
|
transform: hovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '13px' }}>⚙️</span>
|
||||||
|
<span>Advanced</span>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: advanced ? '#10b981' : '#ef4444',
|
||||||
|
boxShadow: advanced
|
||||||
|
? '0 0 4px rgba(16, 185, 129, 0.4)'
|
||||||
|
: '0 0 4px rgba(239, 68, 68, 0.4)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{hovered && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '240px',
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{advanced
|
||||||
|
? 'Advanced mode is ON. Exa and Tavily configuration options are available.'
|
||||||
|
: 'Advanced mode is OFF. Enable to access Exa and Tavily configuration options.'}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%) rotate(45deg)',
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatKeyword } from '../../../../utils/keywordExpansion';
|
||||||
|
|
||||||
|
interface CurrentKeywordsProps {
|
||||||
|
keywords: string[];
|
||||||
|
onRemoveKeyword: (keyword: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentKeywords: React.FC<CurrentKeywordsProps> = ({ keywords, onRemoveKeyword }) => {
|
||||||
|
if (keywords.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '10px',
|
||||||
|
background: 'rgba(241, 245, 249, 0.5)',
|
||||||
|
border: '1px solid rgba(203, 213, 225, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#475569',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
Current Keywords ({keywords.length})
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
{keywords.map((keyword, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid rgba(203, 213, 225, 0.5)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#334155',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatKeyword(keyword)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveKeyword(keyword)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#ef4444',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '0',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'none';
|
||||||
|
}}
|
||||||
|
title="Remove keyword"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
183
frontend/src/components/Research/steps/components/ExaOptions.tsx
Normal file
183
frontend/src/components/Research/steps/components/ExaOptions.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ResearchConfig } from '../../../../services/blogWriterApi';
|
||||||
|
import { exaCategories, exaSearchTypes } from '../utils/constants';
|
||||||
|
|
||||||
|
interface ExaOptionsProps {
|
||||||
|
config: ResearchConfig;
|
||||||
|
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }) => {
|
||||||
|
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
onConfigUpdate({ exa_category: value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value as 'auto' | 'keyword' | 'neural';
|
||||||
|
onConfigUpdate({ exa_search_type: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||||
|
onConfigUpdate({ exa_include_domains: domains });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExcludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||||
|
onConfigUpdate({ exa_exclude_domains: domains });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '14px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '18px' }}>🧠</span>
|
||||||
|
<strong style={{ color: '#6b21a8', fontSize: '13px' }}>Exa Neural Search Options</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Exa Category */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6b21a8',
|
||||||
|
}}>
|
||||||
|
Content Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.exa_category || ''}
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exaCategories.map(cat => (
|
||||||
|
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exa Search Type */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6b21a8',
|
||||||
|
}}>
|
||||||
|
Search Algorithm
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.exa_search_type || 'auto'}
|
||||||
|
onChange={handleSearchTypeChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exaSearchTypes.map(type => (
|
||||||
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Filters */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6b21a8',
|
||||||
|
}}>
|
||||||
|
Include Domains (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.exa_include_domains?.join(', ') || ''}
|
||||||
|
onChange={handleIncludeDomainsChange}
|
||||||
|
placeholder="e.g., nature.com, arxiv.org"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6b21a8',
|
||||||
|
}}>
|
||||||
|
Exclude Domains (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.exa_exclude_domains?.join(', ') || ''}
|
||||||
|
onChange={handleExcludeDomainsChange}
|
||||||
|
placeholder="e.g., spam.com, ads.com"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatKeyword } from '../../../../utils/keywordExpansion';
|
||||||
|
|
||||||
|
interface KeywordExpansionProps {
|
||||||
|
suggestions: string[];
|
||||||
|
currentKeywords: string[];
|
||||||
|
industry: string;
|
||||||
|
onAddSuggestion: (suggestion: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeywordExpansion: React.FC<KeywordExpansionProps> = ({
|
||||||
|
suggestions,
|
||||||
|
currentKeywords,
|
||||||
|
industry,
|
||||||
|
onAddSuggestion,
|
||||||
|
}) => {
|
||||||
|
if (suggestions.length === 0 || industry === 'General') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(147, 197, 253, 0.05) 100%)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.15)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1e40af',
|
||||||
|
}}>
|
||||||
|
<span>💡</span>
|
||||||
|
<span>Suggested Keywords for {industry}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
{suggestions.map((suggestion, idx) => {
|
||||||
|
const isAlreadyAdded = currentKeywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => !isAlreadyAdded && onAddSuggestion(suggestion)}
|
||||||
|
disabled={isAlreadyAdded}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: isAlreadyAdded
|
||||||
|
? 'rgba(203, 213, 225, 0.3)'
|
||||||
|
: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
border: `1px solid ${isAlreadyAdded ? 'rgba(148, 163, 184, 0.3)' : 'rgba(59, 130, 246, 0.2)'}`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: isAlreadyAdded ? '#64748b' : '#1e40af',
|
||||||
|
cursor: isAlreadyAdded ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isAlreadyAdded) {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isAlreadyAdded) {
|
||||||
|
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.2)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAlreadyAdded ? (
|
||||||
|
<>
|
||||||
|
<span>✓</span>
|
||||||
|
<span>{formatKeyword(suggestion)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>+</span>
|
||||||
|
<span>{formatKeyword(suggestion)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#64748b',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}>
|
||||||
|
Click to add suggested keywords to your research query
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||||
|
|
||||||
|
interface ProviderChipsProps {
|
||||||
|
providerAvailability: ProviderAvailability | null;
|
||||||
|
advanced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderChips: React.FC<ProviderChipsProps> = ({ providerAvailability, advanced = false }) => {
|
||||||
|
const [hoveredChip, setHoveredChip] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!providerAvailability) return null;
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{
|
||||||
|
id: 'google',
|
||||||
|
name: 'Google',
|
||||||
|
available: providerAvailability.google_available,
|
||||||
|
status: providerAvailability.gemini_key_status,
|
||||||
|
icon: '🔍',
|
||||||
|
tooltip: 'Google Search powered by Gemini AI. Provides comprehensive web search results with semantic understanding and real-time information from across the web.',
|
||||||
|
color: providerAvailability.google_available
|
||||||
|
? 'linear-gradient(135deg, rgba(66, 133, 244, 0.15) 0%, rgba(52, 168, 83, 0.15) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||||
|
borderColor: providerAvailability.google_available
|
||||||
|
? 'rgba(66, 133, 244, 0.3)'
|
||||||
|
: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
textColor: providerAvailability.google_available ? '#4285f4' : '#ef4444',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exa',
|
||||||
|
name: 'Exa',
|
||||||
|
available: providerAvailability.exa_available,
|
||||||
|
status: providerAvailability.exa_key_status,
|
||||||
|
icon: '🧠',
|
||||||
|
tooltip: 'Exa Neural Search. Advanced semantic search engine that understands context and meaning, providing highly relevant results through neural network-powered query understanding.',
|
||||||
|
// Show green when advanced is ON and available, red when advanced is OFF or not available
|
||||||
|
isAdvanced: true,
|
||||||
|
color: (advanced && providerAvailability.exa_available)
|
||||||
|
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||||
|
borderColor: (advanced && providerAvailability.exa_available)
|
||||||
|
? 'rgba(16, 185, 129, 0.3)'
|
||||||
|
: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
textColor: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
|
||||||
|
chipStatus: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tavily',
|
||||||
|
name: 'Tavily',
|
||||||
|
available: providerAvailability.tavily_available,
|
||||||
|
status: providerAvailability.tavily_key_status,
|
||||||
|
icon: '🤖',
|
||||||
|
tooltip: 'Tavily AI Research Engine. Specialized AI-powered research tool designed for comprehensive content discovery, providing deep insights and structured research data from multiple sources.',
|
||||||
|
// Show green when advanced is ON and available, red when advanced is OFF or not available
|
||||||
|
isAdvanced: true,
|
||||||
|
color: (advanced && providerAvailability.tavily_available)
|
||||||
|
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||||
|
borderColor: (advanced && providerAvailability.tavily_available)
|
||||||
|
? 'rgba(16, 185, 129, 0.3)'
|
||||||
|
: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
textColor: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
|
||||||
|
chipStatus: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginLeft: '16px',
|
||||||
|
}}>
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const isHovered = hoveredChip === provider.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.id}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredChip(provider.id)}
|
||||||
|
onMouseLeave={() => setHoveredChip(null)}
|
||||||
|
>
|
||||||
|
{/* Chip */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
background: provider.color,
|
||||||
|
border: `1px solid ${provider.borderColor}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: provider.textColor,
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
cursor: 'default',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? '0 2px 8px rgba(0, 0, 0, 0.12)'
|
||||||
|
: '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||||
|
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '13px' }}>{provider.icon}</span>
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: (provider as any).chipStatus || (provider.available ? '#10b981' : '#ef4444'),
|
||||||
|
boxShadow: ((provider as any).chipStatus === '#10b981') || (provider.available && !(provider as any).isAdvanced)
|
||||||
|
? '0 0 4px rgba(16, 185, 129, 0.4)'
|
||||||
|
: '0 0 4px rgba(239, 68, 68, 0.4)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{isHovered && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '280px',
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{provider.tooltip}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%) rotate(45deg)',
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
background: 'rgba(15, 23, 42, 0.95)',
|
||||||
|
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||||
|
|
||||||
|
interface ProviderStatusProps {
|
||||||
|
providerAvailability: ProviderAvailability | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderStatus: React.FC<ProviderStatusProps> = ({ providerAvailability }) => {
|
||||||
|
if (!providerAvailability) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(241, 245, 249, 0.5)',
|
||||||
|
border: '1px solid rgba(203, 213, 225, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#64748b',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: '600', color: '#475569' }}>Provider Status:</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: providerAvailability.google_available ? '#10b981' : '#ef4444',
|
||||||
|
}} />
|
||||||
|
<span>Google: {providerAvailability.gemini_key_status}</span>
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: providerAvailability.exa_available ? '#10b981' : '#ef4444',
|
||||||
|
}} />
|
||||||
|
<span>Exa: {providerAvailability.exa_key_status}</span>
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: providerAvailability.tavily_available ? '#10b981' : '#ef4444',
|
||||||
|
}} />
|
||||||
|
<span>Tavily: {providerAvailability.tavily_key_status}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatAngle } from '../../../../utils/researchAngles';
|
||||||
|
|
||||||
|
interface ResearchAnglesProps {
|
||||||
|
angles: string[];
|
||||||
|
onUseAngle: (angle: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResearchAngles: React.FC<ResearchAnglesProps> = ({ angles, onUseAngle }) => {
|
||||||
|
if (angles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
|
||||||
|
border: '1px solid rgba(168, 85, 247, 0.15)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
}}>💡</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#7c3aed',
|
||||||
|
}}>
|
||||||
|
Explore Alternative Research Angles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: '10px',
|
||||||
|
}}>
|
||||||
|
{angles.map((angle, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => onUseAngle(angle)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
border: '1px solid rgba(168, 85, 247, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#6b21a8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
boxShadow: '0 1px 3px rgba(168, 85, 247, 0.1)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.4)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(168, 85, 247, 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.2)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 3px rgba(168, 85, 247, 0.1)';
|
||||||
|
}}
|
||||||
|
title={`Click to research: ${angle}`}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||||
|
<span>{formatAngle(angle)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#64748b',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}>
|
||||||
|
Click any angle to explore a different research focus
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||||
|
import { industries } from '../utils/constants';
|
||||||
|
|
||||||
|
interface ResearchControlsBarProps {
|
||||||
|
industry: string;
|
||||||
|
providerAvailability: ProviderAvailability | null;
|
||||||
|
onIndustryChange: (industry: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResearchControlsBar: React.FC<ResearchControlsBarProps> = ({
|
||||||
|
industry,
|
||||||
|
providerAvailability,
|
||||||
|
onIndustryChange,
|
||||||
|
}) => {
|
||||||
|
const dropdownStyle = {
|
||||||
|
minWidth: '130px',
|
||||||
|
padding: '7px 28px 7px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(15, 23, 42, 0.1)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif',
|
||||||
|
fontWeight: '500',
|
||||||
|
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||||
|
appearance: 'none' as const,
|
||||||
|
WebkitAppearance: 'none' as const,
|
||||||
|
MozAppearance: 'none' as const,
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23475569' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`,
|
||||||
|
backgroundRepeat: 'no-repeat' as const,
|
||||||
|
backgroundPosition: 'right 9px center',
|
||||||
|
backgroundSize: '10px 10px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLSelectElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.08), 0 1px 3px rgba(0, 0, 0, 0.08)';
|
||||||
|
e.currentTarget.style.background = '#ffffff';
|
||||||
|
e.currentTarget.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%230ea5e9' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`;
|
||||||
|
e.currentTarget.style.backgroundSize = '10px 10px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLSelectElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||||
|
e.currentTarget.style.background = '#ffffff';
|
||||||
|
e.currentTarget.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23475569' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`;
|
||||||
|
e.currentTarget.style.backgroundSize = '10px 10px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e: React.MouseEvent<HTMLSelectElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.15)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.06)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e: React.MouseEvent<HTMLSelectElement>) => {
|
||||||
|
if (document.activeElement !== e.currentTarget) {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '10px',
|
||||||
|
paddingTop: '16px',
|
||||||
|
marginTop: '16px',
|
||||||
|
borderTop: '1px solid rgba(14, 165, 233, 0.15)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{/* Compact Dropdowns - Stacked Horizontally */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{/* Industry Dropdown */}
|
||||||
|
<select
|
||||||
|
value={industry}
|
||||||
|
onChange={(e) => onIndustryChange(e.target.value)}
|
||||||
|
title="Select industry for targeted research"
|
||||||
|
style={dropdownStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{industries.map(ind => (
|
||||||
|
<option key={ind} value={ind}>{ind}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
clearResearchHistory,
|
||||||
|
formatHistoryTimestamp,
|
||||||
|
getHistorySummary,
|
||||||
|
ResearchHistoryEntry
|
||||||
|
} from '../../../../utils/researchHistory';
|
||||||
|
import { WizardState } from '../../types/research.types';
|
||||||
|
|
||||||
|
interface ResearchHistoryProps {
|
||||||
|
history: ResearchHistoryEntry[];
|
||||||
|
onLoadHistory: (entry: Partial<WizardState>) => void;
|
||||||
|
onHistoryCleared: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResearchHistory: React.FC<ResearchHistoryProps> = ({
|
||||||
|
history,
|
||||||
|
onLoadHistory,
|
||||||
|
onHistoryCleared
|
||||||
|
}) => {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
clearResearchHistory();
|
||||||
|
onHistoryCleared();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(14, 165, 233, 0.03)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0369a1',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<span>🕐</span>
|
||||||
|
Recently Researched
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#64748b',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(100, 116, 139, 0.2)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
|
||||||
|
e.currentTarget.style.color = '#dc2626';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.2)';
|
||||||
|
e.currentTarget.style.color = '#64748b';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
{history.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.timestamp}
|
||||||
|
onClick={() => {
|
||||||
|
onLoadHistory({
|
||||||
|
keywords: entry.keywords,
|
||||||
|
industry: entry.industry,
|
||||||
|
targetAudience: entry.targetAudience,
|
||||||
|
researchMode: entry.researchMode,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title={`Industry: ${entry.industry} | Audience: ${entry.targetAudience} | Mode: ${entry.researchMode} | ${formatHistoryTimestamp(entry.timestamp)}`}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#0369a1',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||||
|
<span style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '200px',
|
||||||
|
}}>
|
||||||
|
{getHistorySummary(entry)}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#64748b',
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}>
|
||||||
|
{formatHistoryTimestamp(entry.timestamp)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ResearchInputContainerProps {
|
||||||
|
keywords: string[];
|
||||||
|
placeholder: string;
|
||||||
|
onKeywordsChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
|
||||||
|
keywords,
|
||||||
|
placeholder,
|
||||||
|
onKeywordsChange,
|
||||||
|
}) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [wordCount, setWordCount] = useState(0);
|
||||||
|
const MAX_WORDS = 1000;
|
||||||
|
|
||||||
|
// Initialize input value from keywords only on mount or when keywords are cleared
|
||||||
|
useEffect(() => {
|
||||||
|
const keywordValue = keywords.length > 0 ? keywords.join(', ') : '';
|
||||||
|
// Only update if the input is empty or if keywords were cleared
|
||||||
|
if (inputValue === '' || (keywords.length === 0 && inputValue !== '')) {
|
||||||
|
setInputValue(keywordValue);
|
||||||
|
const words = keywordValue.trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
setWordCount(words.length);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [keywords.length]); // Only reinitialize if keywords array length changes
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const words = value.trim().split(/\s+/).filter(w => w.length > 0);
|
||||||
|
const currentWordCount = words.length;
|
||||||
|
|
||||||
|
// Only update if within word limit
|
||||||
|
if (currentWordCount <= MAX_WORDS) {
|
||||||
|
setInputValue(value);
|
||||||
|
setWordCount(currentWordCount);
|
||||||
|
// Create a new event with the current value for the parent handler
|
||||||
|
const syntheticEvent = {
|
||||||
|
...e,
|
||||||
|
target: {
|
||||||
|
...e.target,
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||||
|
onKeywordsChange(syntheticEvent);
|
||||||
|
} else {
|
||||||
|
// Truncate to last valid word boundary
|
||||||
|
const truncatedWords = words.slice(0, MAX_WORDS);
|
||||||
|
const truncatedValue = truncatedWords.join(' ');
|
||||||
|
setInputValue(truncatedValue);
|
||||||
|
setWordCount(MAX_WORDS);
|
||||||
|
// Create synthetic event with truncated value
|
||||||
|
const syntheticEvent = {
|
||||||
|
...e,
|
||||||
|
target: {
|
||||||
|
...e.target,
|
||||||
|
value: truncatedValue,
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||||
|
onKeywordsChange(syntheticEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: '227px', // Reduced by 35% from 350px
|
||||||
|
width: '65%', // Reduced by 35% from 100%
|
||||||
|
padding: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.95) 100%)',
|
||||||
|
boxShadow: 'inset 0 2px 8px rgba(14, 165, 233, 0.06), 0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.3)';
|
||||||
|
e.currentTarget.style.boxShadow = 'inset 0 2px 8px rgba(14, 165, 233, 0.08), 0 2px 4px rgba(0, 0, 0, 0.08)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||||
|
e.currentTarget.style.boxShadow = 'inset 0 2px 8px rgba(14, 165, 233, 0.06), 0 1px 2px rgba(0, 0, 0, 0.05)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Textarea for input - takes full space */}
|
||||||
|
<textarea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
flex: '1',
|
||||||
|
minHeight: '195px', // Reduced by 35% from 300px
|
||||||
|
padding: '12px',
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.7',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#1e293b',
|
||||||
|
resize: 'vertical',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif',
|
||||||
|
outline: 'none',
|
||||||
|
fontWeight: '400',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Word count indicator */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: wordCount >= MAX_WORDS ? '#ef4444' : '#64748b',
|
||||||
|
fontWeight: wordCount >= MAX_WORDS ? '600' : '400',
|
||||||
|
}}>
|
||||||
|
{wordCount} / {MAX_WORDS} words
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SmartInputIndicatorProps {
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartInputIndicator: React.FC<SmartInputIndicatorProps> = ({ keywords }) => {
|
||||||
|
if (keywords.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%)',
|
||||||
|
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#059669',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<span>✓</span>
|
||||||
|
{keywords[0]?.startsWith('http') ? (
|
||||||
|
<span>URL detected - will extract and analyze content</span>
|
||||||
|
) : keywords.length === 1 && keywords[0]?.split(/\s+/).length > 5 ? (
|
||||||
|
<span>Research topic detected - will conduct comprehensive analysis</span>
|
||||||
|
) : (
|
||||||
|
<span>{keywords.length} keyword{keywords.length > 1 ? 's' : ''} identified</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TargetAudienceProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TargetAudience: React.FC<TargetAudienceProps> = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0c4a6e',
|
||||||
|
}}>
|
||||||
|
Target Audience (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="e.g., Marketing professionals, Tech enthusiasts, Business owners"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(14, 165, 233, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ResearchConfig } from '../../../../services/blogWriterApi';
|
||||||
|
import {
|
||||||
|
tavilyTopics,
|
||||||
|
tavilySearchDepths,
|
||||||
|
tavilyTimeRanges,
|
||||||
|
tavilyAnswerOptions,
|
||||||
|
tavilyRawContentOptions
|
||||||
|
} from '../utils/constants';
|
||||||
|
|
||||||
|
interface TavilyOptionsProps {
|
||||||
|
config: ResearchConfig;
|
||||||
|
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUpdate }) => {
|
||||||
|
const handleTopicChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value as 'general' | 'news' | 'finance';
|
||||||
|
onConfigUpdate({ tavily_topic: value || 'general' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchDepthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value as 'basic' | 'advanced';
|
||||||
|
onConfigUpdate({ tavily_search_depth: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||||
|
onConfigUpdate({ tavily_include_domains: domains });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExcludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||||
|
onConfigUpdate({ tavily_exclude_domains: domains });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeAnswerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
let answerValue: boolean | 'basic' | 'advanced';
|
||||||
|
if (value === 'true') {
|
||||||
|
answerValue = true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
answerValue = false;
|
||||||
|
} else {
|
||||||
|
answerValue = value as 'basic' | 'advanced';
|
||||||
|
}
|
||||||
|
onConfigUpdate({ tavily_include_answer: answerValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const timeRangeValue = value ? (value as 'day' | 'week' | 'month' | 'year' | 'd' | 'w' | 'm' | 'y') : undefined;
|
||||||
|
onConfigUpdate({ tavily_time_range: timeRangeValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeRawContentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
let rawContentValue: boolean | 'markdown' | 'text';
|
||||||
|
if (value === 'true') {
|
||||||
|
rawContentValue = true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
rawContentValue = false;
|
||||||
|
} else {
|
||||||
|
rawContentValue = value as 'markdown' | 'text';
|
||||||
|
}
|
||||||
|
onConfigUpdate({ tavily_include_raw_content: rawContentValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_include_images: e.target.checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeImageDescriptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_include_image_descriptions: e.target.checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncludeFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_include_favicon: e.target.checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_start_date: e.target.value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_end_date: e.target.value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCountryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_country: e.target.value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChunksPerSourceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 3) {
|
||||||
|
onConfigUpdate({ tavily_chunks_per_source: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoParametersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onConfigUpdate({ tavily_auto_parameters: e.target.checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||||
|
border: '2px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '14px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '14px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '18px' }}>🤖</span>
|
||||||
|
<strong style={{ color: '#0ea5e9', fontSize: '13px' }}>Tavily AI Search Options</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Tavily Topic */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Search Topic
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.tavily_topic || 'general'}
|
||||||
|
onChange={handleTopicChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tavilyTopics.map(topic => (
|
||||||
|
<option key={topic.value} value={topic.value}>{topic.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tavily Search Depth */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Search Depth
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.tavily_search_depth || 'basic'}
|
||||||
|
onChange={handleSearchDepthChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tavilySearchDepths.map(depth => (
|
||||||
|
<option key={depth.value} value={depth.value}>{depth.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tavily Include Answer */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
AI Answer
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.tavily_include_answer === true ? 'true' : typeof config.tavily_include_answer === 'string' ? config.tavily_include_answer : 'false'}
|
||||||
|
onChange={handleIncludeAnswerChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tavilyAnswerOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tavily Time Range */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Time Range
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.tavily_time_range || ''}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tavilyTimeRanges.map(range => (
|
||||||
|
<option key={range.value} value={range.value}>{range.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Filters */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Include Domains (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.tavily_include_domains?.join(', ') || ''}
|
||||||
|
onChange={handleIncludeDomainsChange}
|
||||||
|
placeholder="e.g., nature.com, arxiv.org"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Exclude Domains (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.tavily_exclude_domains?.join(', ') || ''}
|
||||||
|
onChange={handleExcludeDomainsChange}
|
||||||
|
placeholder="e.g., spam.com, ads.com"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Tavily Options */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Include Raw Content */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Raw Content Format
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.tavily_include_raw_content === true ? 'true' : typeof config.tavily_include_raw_content === 'string' ? config.tavily_include_raw_content : 'false'}
|
||||||
|
onChange={handleIncludeRawContentChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tavilyRawContentOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Country */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Country Code (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.tavily_country || ''}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
placeholder="e.g., US, GB, IN"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chunks Per Source (only for advanced) */}
|
||||||
|
{config.tavily_search_depth === 'advanced' && (
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Chunks Per Source (1-3)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
value={config.tavily_chunks_per_source || 3}
|
||||||
|
onChange={handleChunksPerSourceChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
Start Date (YYYY-MM-DD)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={config.tavily_start_date || ''}
|
||||||
|
onChange={handleStartDateChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
}}>
|
||||||
|
End Date (YYYY-MM-DD)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={config.tavily_end_date || ''}
|
||||||
|
onChange={handleEndDateChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkboxes */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}>
|
||||||
|
<label style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.tavily_include_images || false}
|
||||||
|
onChange={handleIncludeImagesChange}
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Include Images</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.tavily_include_image_descriptions || false}
|
||||||
|
onChange={handleIncludeImageDescriptionsChange}
|
||||||
|
disabled={!config.tavily_include_images}
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
cursor: config.tavily_include_images ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: config.tavily_include_images ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Include Image Descriptions</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.tavily_include_favicon || false}
|
||||||
|
onChange={handleIncludeFaviconChange}
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Include Favicon URLs</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.tavily_auto_parameters || false}
|
||||||
|
onChange={handleAutoParametersChange}
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Auto-Configure Parameters</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
83
frontend/src/components/Research/steps/utils/constants.ts
Normal file
83
frontend/src/components/Research/steps/utils/constants.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const industries = [
|
||||||
|
'General',
|
||||||
|
'Technology',
|
||||||
|
'Business',
|
||||||
|
'Marketing',
|
||||||
|
'Finance',
|
||||||
|
'Healthcare',
|
||||||
|
'Education',
|
||||||
|
'Real Estate',
|
||||||
|
'Entertainment',
|
||||||
|
'Food & Beverage',
|
||||||
|
'Travel',
|
||||||
|
'Fashion',
|
||||||
|
'Sports',
|
||||||
|
'Science',
|
||||||
|
'Law',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const researchModes = [
|
||||||
|
{ value: 'basic', label: 'Basic - Quick insights' },
|
||||||
|
{ value: 'comprehensive', label: 'Comprehensive - In-depth analysis' },
|
||||||
|
{ value: 'targeted', label: 'Targeted - Specific focus' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const providers = [
|
||||||
|
{ value: 'google', label: '🔍 Google Search' },
|
||||||
|
{ value: 'exa', label: '🧠 Exa Neural Search' },
|
||||||
|
{ value: 'tavily', label: '🤖 Tavily AI Search' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const exaCategories = [
|
||||||
|
{ value: '', label: 'All Categories' },
|
||||||
|
{ value: 'company', label: 'Company Profiles' },
|
||||||
|
{ value: 'research paper', label: 'Research Papers' },
|
||||||
|
{ value: 'news', label: 'News Articles' },
|
||||||
|
{ value: 'linkedin profile', label: 'LinkedIn Profiles' },
|
||||||
|
{ value: 'github', label: 'GitHub Repos' },
|
||||||
|
{ value: 'tweet', label: 'Tweets' },
|
||||||
|
{ value: 'movie', label: 'Movies' },
|
||||||
|
{ value: 'song', label: 'Songs' },
|
||||||
|
{ value: 'personal site', label: 'Personal Sites' },
|
||||||
|
{ value: 'pdf', label: 'PDF Documents' },
|
||||||
|
{ value: 'financial report', label: 'Financial Reports' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const exaSearchTypes = [
|
||||||
|
{ value: 'auto', label: 'Auto - Let AI decide' },
|
||||||
|
{ value: 'keyword', label: 'Keyword - Precise matching' },
|
||||||
|
{ value: 'neural', label: 'Neural - Semantic search' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tavilyTopics = [
|
||||||
|
{ value: 'general', label: 'General' },
|
||||||
|
{ value: 'news', label: 'News' },
|
||||||
|
{ value: 'finance', label: 'Finance' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tavilySearchDepths = [
|
||||||
|
{ value: 'basic', label: 'Basic (1 credit) - Fast search' },
|
||||||
|
{ value: 'advanced', label: 'Advanced (2 credits) - Deep analysis' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tavilyTimeRanges = [
|
||||||
|
{ value: '', label: 'No time filter' },
|
||||||
|
{ value: 'day', label: 'Last 24 hours' },
|
||||||
|
{ value: 'week', label: 'Last week' },
|
||||||
|
{ value: 'month', label: 'Last month' },
|
||||||
|
{ value: 'year', label: 'Last year' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tavilyAnswerOptions = [
|
||||||
|
{ value: 'false', label: 'No answer' },
|
||||||
|
{ value: 'basic', label: 'Basic answer' },
|
||||||
|
{ value: 'advanced', label: 'Advanced answer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tavilyRawContentOptions = [
|
||||||
|
{ value: 'false', label: 'No raw content' },
|
||||||
|
{ value: 'markdown', label: 'Markdown format' },
|
||||||
|
{ value: 'text', label: 'Plain text' },
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Industry-specific domain suggestions and Exa category mappings
|
||||||
|
*/
|
||||||
|
export const getIndustryDomainSuggestions = (industry: string): string[] => {
|
||||||
|
const domainMap: Record<string, string[]> = {
|
||||||
|
'Healthcare': ['pubmed.gov', 'nejm.org', 'thelancet.com', 'nih.gov'],
|
||||||
|
'Technology': ['techcrunch.com', 'wired.com', 'arstechnica.com', 'theverge.com'],
|
||||||
|
'Finance': ['wsj.com', 'bloomberg.com', 'ft.com', 'reuters.com'],
|
||||||
|
'Science': ['nature.com', 'sciencemag.org', 'cell.com', 'pnas.org'],
|
||||||
|
'Business': ['hbr.org', 'forbes.com', 'businessinsider.com', 'mckinsey.com'],
|
||||||
|
'Marketing': ['marketingland.com', 'adweek.com', 'hubspot.com', 'moz.com'],
|
||||||
|
'Education': ['edutopia.org', 'chronicle.com', 'insidehighered.com'],
|
||||||
|
'Real Estate': ['realtor.com', 'zillow.com', 'forbes.com'],
|
||||||
|
'Entertainment': ['variety.com', 'hollywoodreporter.com', 'deadline.com'],
|
||||||
|
'Travel': ['lonelyplanet.com', 'nationalgeographic.com', 'travelandleisure.com'],
|
||||||
|
'Fashion': ['vogue.com', 'elle.com', 'wwd.com'],
|
||||||
|
'Sports': ['espn.com', 'si.com', 'bleacherreport.com'],
|
||||||
|
'Law': ['law.com', 'abajournal.com', 'scotusblog.com'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return domainMap[industry] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIndustryExaCategory = (industry: string): string | undefined => {
|
||||||
|
const categoryMap: Record<string, string> = {
|
||||||
|
'Healthcare': 'research paper',
|
||||||
|
'Science': 'research paper',
|
||||||
|
'Finance': 'financial report',
|
||||||
|
'Technology': 'company',
|
||||||
|
'Business': 'company',
|
||||||
|
'Marketing': 'company',
|
||||||
|
'Education': 'research paper',
|
||||||
|
'Law': 'pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
return categoryMap[industry];
|
||||||
|
};
|
||||||
|
|
||||||
32
frontend/src/components/Research/steps/utils/inputParser.ts
Normal file
32
frontend/src/components/Research/steps/utils/inputParser.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Intelligent input parser - handles sentences, keywords, URLs
|
||||||
|
*/
|
||||||
|
export const parseIntelligentInput = (value: string): string[] => {
|
||||||
|
// If empty, return empty array
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
|
||||||
|
// Detect if input contains URLs
|
||||||
|
const urlPattern = /(https?:\/\/[^\s,]+)/g;
|
||||||
|
const urls = value.match(urlPattern) || [];
|
||||||
|
|
||||||
|
// Check if input looks like a sentence/paragraph (contains multiple words without commas)
|
||||||
|
const hasCommas = value.includes(',');
|
||||||
|
const wordCount = value.trim().split(/\s+/).length;
|
||||||
|
|
||||||
|
if (urls.length > 0) {
|
||||||
|
// User provided URLs - extract them as separate keywords
|
||||||
|
const textWithoutUrls = value.replace(urlPattern, '').trim();
|
||||||
|
const textKeywords = textWithoutUrls ? [textWithoutUrls] : [];
|
||||||
|
return [...urls, ...textKeywords];
|
||||||
|
} else if (!hasCommas && wordCount > 5) {
|
||||||
|
// Looks like a sentence/paragraph - treat entire input as single research topic
|
||||||
|
return [value.trim()];
|
||||||
|
} else if (hasCommas) {
|
||||||
|
// Traditional comma-separated keywords
|
||||||
|
return value.split(',').map(k => k.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
// Short phrase or single keyword
|
||||||
|
return [value.trim()];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
58
frontend/src/components/Research/steps/utils/placeholders.ts
Normal file
58
frontend/src/components/Research/steps/utils/placeholders.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Industry-specific placeholder examples for personalized experience
|
||||||
|
*/
|
||||||
|
export const getIndustryPlaceholders = (industry: string): string[] => {
|
||||||
|
const industryExamples: Record<string, string[]> = {
|
||||||
|
Healthcare: [
|
||||||
|
"Research: AI-powered diagnostic tools in clinical practice\n\n💡 What you'll get:\n• FDA-approved AI medical devices\n• Clinical accuracy and patient outcomes\n• Implementation costs and ROI",
|
||||||
|
"Analyze: Telemedicine adoption trends and patient satisfaction\n\n💡 Research includes:\n• Post-pandemic telehealth growth\n• Remote patient monitoring technologies\n• Insurance coverage and reimbursement",
|
||||||
|
"Investigate: Personalized medicine and genomic testing advances\n\n💡 You'll discover:\n• Latest genomic sequencing technologies\n• Precision therapy success rates\n• Ethical considerations and regulations"
|
||||||
|
],
|
||||||
|
Technology: [
|
||||||
|
"Investigate: Latest developments in edge computing and IoT\n\n💡 What you'll get:\n• Edge AI deployment strategies\n• 5G integration and performance\n• Industry use cases and benchmarks",
|
||||||
|
"Compare: Cloud providers for enterprise SaaS applications\n\n💡 Research includes:\n• AWS vs Azure vs GCP feature comparison\n• Cost optimization strategies\n• Security and compliance certifications",
|
||||||
|
"Analyze: Quantum computing breakthroughs and commercial applications\n\n💡 You'll discover:\n• Latest quantum hardware developments\n• Real-world problem solving examples\n• Investment landscape and timeline"
|
||||||
|
],
|
||||||
|
Finance: [
|
||||||
|
"Research: DeFi regulatory landscape and compliance challenges\n\n💡 What you'll get:\n• Global regulatory frameworks\n• Compliance best practices\n• Risk management strategies",
|
||||||
|
"Analyze: Digital banking customer retention strategies\n\n💡 Research includes:\n• Neobank growth and market share\n• Customer acquisition costs and LTV\n• Personalization and UX innovations",
|
||||||
|
"Investigate: ESG investing trends and impact measurement\n\n💡 You'll discover:\n• ESG rating methodologies\n• Fund performance and returns\n• Regulatory requirements and reporting"
|
||||||
|
],
|
||||||
|
Marketing: [
|
||||||
|
"Research: AI-powered marketing automation and personalization\n\n💡 What you'll get:\n• Top marketing AI platforms and features\n• ROI and conversion rate improvements\n• Implementation case studies",
|
||||||
|
"Analyze: Influencer marketing ROI and authenticity trends\n\n💡 Research includes:\n• Micro vs macro influencer effectiveness\n• Platform-specific engagement rates\n• Brand partnership best practices",
|
||||||
|
"Investigate: Privacy-first marketing in a cookieless world\n\n💡 You'll discover:\n• First-party data strategies\n• Contextual targeting innovations\n• Compliance with privacy regulations"
|
||||||
|
],
|
||||||
|
Business: [
|
||||||
|
"Research: Remote work policies and hybrid workplace models\n\n💡 What you'll get:\n• Productivity metrics and employee satisfaction\n• Technology infrastructure requirements\n• Cultural impact and change management",
|
||||||
|
"Analyze: Supply chain resilience and diversification strategies\n\n💡 Research includes:\n• Nearshoring and reshoring trends\n• Technology solutions for visibility\n• Risk mitigation frameworks",
|
||||||
|
"Investigate: Sustainability initiatives and corporate ESG programs\n\n💡 You'll discover:\n• Industry-specific sustainability benchmarks\n• Cost-benefit analysis of green initiatives\n• Stakeholder communication strategies"
|
||||||
|
],
|
||||||
|
Education: [
|
||||||
|
"Research: EdTech tools for personalized learning experiences\n\n💡 What you'll get:\n• Adaptive learning platform comparisons\n• Student engagement and outcomes data\n• Implementation costs and training needs",
|
||||||
|
"Analyze: Microlearning and skill-based education trends\n\n💡 Research includes:\n• Corporate training effectiveness\n• Platform and content recommendations\n• ROI and completion rates",
|
||||||
|
"Investigate: AI tutoring systems and student support tools\n\n💡 You'll discover:\n• Natural language processing advances\n• Student performance improvements\n• Accessibility and inclusion features"
|
||||||
|
],
|
||||||
|
'Real Estate': [
|
||||||
|
"Research: PropTech innovations transforming property management\n\n💡 What you'll get:\n• Smart building technologies and IoT\n• Tenant experience platforms\n• Operational efficiency gains",
|
||||||
|
"Analyze: Virtual staging and 3D property tours adoption\n\n💡 Research includes:\n• Technology provider comparisons\n• Impact on sales velocity and pricing\n• Cost vs traditional staging",
|
||||||
|
"Investigate: Real estate tokenization and fractional ownership\n\n💡 You'll discover:\n• Blockchain platforms and regulations\n• Investor demographics and demand\n• Liquidity and exit strategies"
|
||||||
|
],
|
||||||
|
Travel: [
|
||||||
|
"Research: Sustainable tourism trends and eco-travel preferences\n\n💡 What you'll get:\n• Green certification programs\n• Traveler willingness to pay premium\n• Destination best practices",
|
||||||
|
"Analyze: AI-powered travel personalization and recommendations\n\n💡 Research includes:\n• Recommendation engine technologies\n• Booking conversion rate improvements\n• Customer lifetime value impact",
|
||||||
|
"Investigate: Bleisure travel and workation destination trends\n\n💡 You'll discover:\n• Remote work-friendly destinations\n• Co-working and accommodation options\n• Digital nomad demographics"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return industryExamples[industry] || [
|
||||||
|
"Research: Latest AI advancements in your industry\n\n💡 What you'll get:\n• Recent breakthroughs and innovations\n• Key companies and technologies\n• Expert insights and market trends",
|
||||||
|
|
||||||
|
"Write a blog on: Emerging trends shaping your industry in 2025\n\n💡 This will research:\n• Technology disruptions and innovations\n• Regulatory changes and compliance\n• Consumer behavior shifts",
|
||||||
|
|
||||||
|
"Analyze: Best practices and success stories in your field\n\n💡 Research includes:\n• Industry leader strategies\n• Implementation case studies\n• ROI and performance metrics",
|
||||||
|
|
||||||
|
"https://example.com/article\n\n💡 URL detected! Research will:\n• Extract key insights from the article\n• Find related sources and updates\n• Provide comprehensive context"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ResearchMode } from '../../../../services/blogWriterApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart mode suggestion based on query complexity
|
||||||
|
*/
|
||||||
|
export const suggestResearchMode = (keywords: string[]): ResearchMode => {
|
||||||
|
if (keywords.length === 0) return 'basic';
|
||||||
|
|
||||||
|
const totalText = keywords.join(' ');
|
||||||
|
const totalWords = totalText.split(/\s+/).length;
|
||||||
|
const hasURL = keywords.some(k => k.startsWith('http'));
|
||||||
|
|
||||||
|
// URL detected → comprehensive research
|
||||||
|
if (hasURL) return 'comprehensive';
|
||||||
|
|
||||||
|
// Long detailed query → comprehensive
|
||||||
|
if (totalWords > 20) return 'comprehensive';
|
||||||
|
|
||||||
|
// Medium complexity → targeted
|
||||||
|
if (totalWords > 10 || keywords.length > 3) return 'targeted';
|
||||||
|
|
||||||
|
// Simple query → basic
|
||||||
|
return 'basic';
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* OAuth Token Status Component
|
* OAuth Token Status Component
|
||||||
* Compact terminal-themed component for displaying OAuth token monitoring status
|
* Compact terminal-themed component for displaying OAuth token monitoring status
|
||||||
|
* with platform-specific execution logs in expanded sections
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -30,8 +33,11 @@ import { useAuth } from '@clerk/clerk-react';
|
|||||||
import {
|
import {
|
||||||
getOAuthTokenStatus,
|
getOAuthTokenStatus,
|
||||||
manualRefreshToken,
|
manualRefreshToken,
|
||||||
|
getOAuthTokenExecutionLogs,
|
||||||
OAuthTokenStatusResponse,
|
OAuthTokenStatusResponse,
|
||||||
ManualRefreshResponse,
|
ManualRefreshResponse,
|
||||||
|
ExecutionLog,
|
||||||
|
ExecutionLogsResponse,
|
||||||
} from '../../api/oauthTokenMonitoring';
|
} from '../../api/oauthTokenMonitoring';
|
||||||
import {
|
import {
|
||||||
TerminalPaper,
|
TerminalPaper,
|
||||||
@@ -41,6 +47,8 @@ import {
|
|||||||
TerminalChipError,
|
TerminalChipError,
|
||||||
TerminalChipWarning,
|
TerminalChipWarning,
|
||||||
TerminalAlert,
|
TerminalAlert,
|
||||||
|
TerminalTableCell,
|
||||||
|
TerminalTableRow,
|
||||||
terminalColors,
|
terminalColors,
|
||||||
} from './terminalTheme';
|
} from './terminalTheme';
|
||||||
|
|
||||||
@@ -48,6 +56,14 @@ interface OAuthTokenStatusProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlatformLogs {
|
||||||
|
[platform: string]: {
|
||||||
|
logs: ExecutionLog[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
|
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
|
||||||
const { userId } = useAuth();
|
const { userId } = useAuth();
|
||||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||||
@@ -55,6 +71,8 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||||
|
const [platformLogs, setPlatformLogs] = useState<PlatformLogs>({});
|
||||||
|
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -72,6 +90,48 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPlatformLogs = async (platform: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Initialize platform logs state if not exists
|
||||||
|
if (!platformLogs[platform]) {
|
||||||
|
setPlatformLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[platform]: { logs: [], loading: false, error: null }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlatformLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[platform]: { ...prev[platform], loading: true, error: null }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getOAuthTokenExecutionLogs(userId, platform, 10, 0); // Get latest 10 logs
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setPlatformLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[platform]: {
|
||||||
|
logs: response.data.logs || [],
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPlatformLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[platform]: {
|
||||||
|
...prev[platform],
|
||||||
|
loading: false,
|
||||||
|
error: err.message || 'Failed to fetch logs'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.error(`Error fetching logs for ${platform}:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
||||||
@@ -79,6 +139,13 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
const interval = setInterval(fetchStatus, 120000);
|
const interval = setInterval(fetchStatus, 120000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
// Fetch logs when platform is expanded
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedPlatform && userId) {
|
||||||
|
fetchPlatformLogs(expandedPlatform);
|
||||||
|
}
|
||||||
|
}, [expandedPlatform, userId]);
|
||||||
|
|
||||||
const handleRefresh = async (platform: string) => {
|
const handleRefresh = async (platform: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -91,6 +158,11 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
// Refresh status after manual refresh
|
// Refresh status after manual refresh
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
|
|
||||||
|
// Refresh logs if platform is expanded
|
||||||
|
if (expandedPlatform === platform) {
|
||||||
|
await fetchPlatformLogs(platform);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
console.log(`Token refresh successful for ${platform}`);
|
console.log(`Token refresh successful for ${platform}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -103,6 +175,14 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
setRefreshing(null);
|
setRefreshing(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExpandPlatform = (platform: string) => {
|
||||||
|
if (expandedPlatform === platform) {
|
||||||
|
setExpandedPlatform(null);
|
||||||
|
} else {
|
||||||
|
setExpandedPlatform(platform);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
@@ -165,6 +245,39 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
};
|
};
|
||||||
return names[platform] || platform.toUpperCase();
|
return names[platform] || platform.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLogStatusChip = (logStatus: string) => {
|
||||||
|
switch (logStatus) {
|
||||||
|
case 'success':
|
||||||
|
return <TerminalChipSuccess label="Success" size="small" />;
|
||||||
|
case 'failed':
|
||||||
|
return <TerminalChipError label="Failed" size="small" />;
|
||||||
|
case 'running':
|
||||||
|
return <TerminalChipWarning label="Running" size="small" />;
|
||||||
|
default:
|
||||||
|
return <Chip label={logStatus} size="small" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLogResult = (resultData: any): string => {
|
||||||
|
if (!resultData) return 'N/A';
|
||||||
|
if (typeof resultData === 'string') {
|
||||||
|
try {
|
||||||
|
resultData = JSON.parse(resultData);
|
||||||
|
} catch {
|
||||||
|
return resultData.substring(0, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultData.token_status) {
|
||||||
|
return `Token: ${resultData.token_status}`;
|
||||||
|
}
|
||||||
|
if (resultData.platform) {
|
||||||
|
return `Platform: ${resultData.platform}`;
|
||||||
|
}
|
||||||
|
const str = JSON.stringify(resultData);
|
||||||
|
return str.length > 60 ? str.substring(0, 60) + '...' : str;
|
||||||
|
};
|
||||||
|
|
||||||
if (loading && !status) {
|
if (loading && !status) {
|
||||||
return (
|
return (
|
||||||
@@ -231,6 +344,7 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
const platformStatus = status.data.platform_status[platform];
|
const platformStatus = status.data.platform_status[platform];
|
||||||
const task = platformStatus?.monitoring_task;
|
const task = platformStatus?.monitoring_task;
|
||||||
const isExpanded = expandedPlatform === platform;
|
const isExpanded = expandedPlatform === platform;
|
||||||
|
const logs = platformLogs[platform];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={platform}>
|
<React.Fragment key={platform}>
|
||||||
@@ -251,7 +365,47 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
<Box display="flex" alignItems="center" gap={1} flexWrap="wrap">
|
||||||
|
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
||||||
|
{task?.last_success && (
|
||||||
|
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||||
|
<Chip
|
||||||
|
label={`✓ ${formatDate(task.last_success).split(',')[0].trim()}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: terminalColors.success + '40',
|
||||||
|
color: terminalColors.success,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: '20px',
|
||||||
|
border: `1px solid ${terminalColors.success}40`,
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
padding: '0 6px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{task?.next_check && (
|
||||||
|
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||||
|
<Chip
|
||||||
|
label={`⏱ ${formatDate(task.next_check).split(',')[0].trim()}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: terminalColors.info + '40',
|
||||||
|
color: terminalColors.info,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: '20px',
|
||||||
|
border: `1px solid ${terminalColors.info}40`,
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
padding: '0 6px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||||
@@ -263,7 +417,7 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
|
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setExpandedPlatform(isExpanded ? null : platform)}
|
onClick={() => handleExpandPlatform(platform)}
|
||||||
sx={{
|
sx={{
|
||||||
color: terminalColors.primary,
|
color: terminalColors.primary,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@@ -318,20 +472,162 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
|||||||
</TerminalTypography>
|
</TerminalTypography>
|
||||||
</TerminalAlert>
|
</TerminalAlert>
|
||||||
)}
|
)}
|
||||||
{task?.last_success && (
|
{/* OAuth Monitoring Logs Section */}
|
||||||
<TerminalAlert severity="success" sx={{ mb: 1 }}>
|
{platformStatus?.connected && (
|
||||||
<TerminalTypography variant="body2">
|
<>
|
||||||
Last successful: {formatDate(task.last_success)}
|
<Divider sx={{ my: 1.5, borderColor: terminalColors.primary + '40' }} />
|
||||||
|
<TerminalTypography variant="subtitle2" fontWeight="bold" mb={1}>
|
||||||
|
🔐 Monitoring Logs
|
||||||
</TerminalTypography>
|
</TerminalTypography>
|
||||||
</TerminalAlert>
|
|
||||||
)}
|
{logs?.loading ? (
|
||||||
{task?.next_check && (
|
<Box display="flex" alignItems="center" gap={1} p={1}>
|
||||||
<Box mt={1}>
|
<CircularProgress size={16} sx={{ color: terminalColors.primary }} />
|
||||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||||
Next check: {formatDate(task.next_check)}
|
Loading logs...
|
||||||
</TerminalTypography>
|
</TerminalTypography>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : logs?.error ? (
|
||||||
|
<TerminalAlert severity="error" sx={{ mb: 1 }}>
|
||||||
|
<TerminalTypography variant="caption">
|
||||||
|
{logs.error}
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalAlert>
|
||||||
|
) : logs?.logs && logs.logs.length > 0 ? (
|
||||||
|
<Box sx={{
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '8px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: terminalColors.primary + '80',
|
||||||
|
borderRadius: '4px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: terminalColors.primary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Table size="small" sx={{
|
||||||
|
'& .MuiTableCell-root': {
|
||||||
|
color: terminalColors.primary,
|
||||||
|
borderColor: terminalColors.primary + '30',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
py: 0.5
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<TableHead sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'rgba(0, 0, 0, 0.8)' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TerminalTableCell>Date</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Status</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Result</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Duration</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{logs.logs.map((log) => (
|
||||||
|
<React.Fragment key={log.id}>
|
||||||
|
<TerminalTableRow
|
||||||
|
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||||
|
onMouseLeave={() => setHoveredLogId(null)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<TerminalTypography variant="caption" fontSize="0.65rem">
|
||||||
|
{formatDate(log.execution_date)}
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
{getLogStatusChip(log.status)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<TerminalTypography variant="caption" fontSize="0.65rem" sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: terminalColors.info,
|
||||||
|
maxWidth: '200px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{formatLogResult(log.result_data)}
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<TerminalTypography variant="caption" fontSize="0.65rem">
|
||||||
|
{log.execution_time_ms ? `${log.execution_time_ms}ms` : 'N/A'}
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalTableCell>
|
||||||
|
</TerminalTableRow>
|
||||||
|
{hoveredLogId === log.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ py: 1, backgroundColor: 'rgba(0, 255, 0, 0.08)', borderLeft: `3px solid ${terminalColors.primary}` }}>
|
||||||
|
<Box pl={2}>
|
||||||
|
<TerminalTypography variant="caption" fontWeight="bold" mb={0.5} display="block">
|
||||||
|
Full Details:
|
||||||
|
</TerminalTypography>
|
||||||
|
{log.error_message && (
|
||||||
|
<Box mb={1}>
|
||||||
|
<TerminalTypography variant="caption" fontWeight="bold" color={terminalColors.error} display="block" mb={0.5}>
|
||||||
|
Error:
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" fontSize="0.6rem" sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: terminalColors.error,
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{log.error_message}
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{log.result_data && (
|
||||||
|
<Box>
|
||||||
|
<TerminalTypography variant="caption" fontWeight="bold" color={terminalColors.info} display="block" mb={0.5}>
|
||||||
|
Result Data:
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" fontSize="0.6rem" sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: terminalColors.info,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}>
|
||||||
|
{typeof log.result_data === 'string' ? log.result_data : JSON.stringify(log.result_data, null, 2)}
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{logs.logs.length >= 10 && (
|
||||||
|
<Box mt={1} textAlign="center">
|
||||||
|
<TerminalTypography variant="caption" color={terminalColors.textSecondary} sx={{ fontStyle: 'italic' }}>
|
||||||
|
Showing latest 10 logs. View all logs in OAuth Monitoring section.
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TerminalTypography variant="caption" color={terminalColors.textSecondary} sx={{ fontStyle: 'italic' }}>
|
||||||
|
No monitoring logs available yet. Logs will appear after the first scheduled check.
|
||||||
|
</TerminalTypography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Existing connection status messages */}
|
||||||
{!task && platformStatus?.connected && (
|
{!task && platformStatus?.connected && (
|
||||||
<TerminalAlert severity="info">
|
<TerminalAlert severity="info">
|
||||||
<TerminalTypography variant="body2">
|
<TerminalTypography variant="body2">
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* Platform Insights Status Component
|
||||||
|
* Compact terminal-themed component for displaying platform insights (GSC/Bing) task status
|
||||||
|
* with execution logs in expanded sections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Search,
|
||||||
|
Globe,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
import {
|
||||||
|
getPlatformInsightsStatus,
|
||||||
|
getPlatformInsightsLogs,
|
||||||
|
PlatformInsightsStatusResponse,
|
||||||
|
PlatformInsightsTask,
|
||||||
|
PlatformInsightsExecutionLog,
|
||||||
|
PlatformInsightsLogsResponse,
|
||||||
|
} from '../../api/platformInsightsMonitoring';
|
||||||
|
import {
|
||||||
|
TerminalPaper,
|
||||||
|
TerminalTypography,
|
||||||
|
TerminalChip,
|
||||||
|
TerminalChipSuccess,
|
||||||
|
TerminalChipError,
|
||||||
|
TerminalChipWarning,
|
||||||
|
TerminalAlert,
|
||||||
|
TerminalTableCell,
|
||||||
|
TerminalTableRow,
|
||||||
|
terminalColors,
|
||||||
|
} from './terminalTheme';
|
||||||
|
|
||||||
|
interface PlatformInsightsStatusProps {
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskLogs {
|
||||||
|
[taskId: number]: {
|
||||||
|
logs: PlatformInsightsExecutionLog[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlatformInsightsStatus: React.FC<PlatformInsightsStatusProps> = ({ compact = true }) => {
|
||||||
|
const { userId } = useAuth();
|
||||||
|
const [status, setStatus] = useState<PlatformInsightsStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedTaskId, setExpandedTaskId] = useState<number | null>(null);
|
||||||
|
const [taskLogs, setTaskLogs] = useState<TaskLogs>({});
|
||||||
|
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await getPlatformInsightsStatus(userId);
|
||||||
|
setStatus(response);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch platform insights status');
|
||||||
|
console.error('Error fetching platform insights status:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTaskLogs = async (taskId: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Initialize task logs state if not exists
|
||||||
|
if (!taskLogs[taskId]) {
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { logs: [], loading: true, error: null }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { ...prev[taskId], loading: true, error: null }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[PlatformInsights] Fetching logs for task ${taskId}...`);
|
||||||
|
const response = await getPlatformInsightsLogs(userId, 10, taskId);
|
||||||
|
console.log(`[PlatformInsights] Received logs response:`, {
|
||||||
|
success: response.success,
|
||||||
|
logsCount: response.logs?.length || 0,
|
||||||
|
totalCount: response.total_count,
|
||||||
|
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||||
|
firstLog: response.logs?.[0] || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.logs && Array.isArray(response.logs)) {
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: {
|
||||||
|
logs: response.logs,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.warn(`[PlatformInsights] Invalid logs response structure:`, response);
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: {
|
||||||
|
logs: prev[taskId]?.logs || [],
|
||||||
|
loading: false,
|
||||||
|
error: response.success === false ? 'Failed to fetch logs' : 'Invalid response structure'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[PlatformInsights] Error fetching logs for task ${taskId}:`, err);
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: {
|
||||||
|
logs: prev[taskId]?.logs || [],
|
||||||
|
loading: false,
|
||||||
|
error: err.message || 'Failed to fetch logs'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = (taskId: number) => {
|
||||||
|
if (expandedTaskId === taskId) {
|
||||||
|
setExpandedTaskId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedTaskId(taskId);
|
||||||
|
// Always fetch logs when expanding to get latest data
|
||||||
|
fetchTaskLogs(taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
// Refresh every 5 minutes (same as other dashboard components)
|
||||||
|
// Tasks only run weekly, so frequent polling is unnecessary
|
||||||
|
const interval = setInterval(fetchStatus, 5 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Fetch logs when task is expanded (similar to OAuth pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedTaskId && userId) {
|
||||||
|
fetchTaskLogs(expandedTaskId);
|
||||||
|
}
|
||||||
|
}, [expandedTaskId, userId]);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number | null) => {
|
||||||
|
if (!ms) return 'N/A';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return <CheckCircle size={16} color={terminalColors.success} />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle size={16} color={terminalColors.error} />;
|
||||||
|
case 'paused':
|
||||||
|
return <AlertTriangle size={16} color={terminalColors.warning} />;
|
||||||
|
default:
|
||||||
|
return <Info size={16} color={terminalColors.info} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return <TerminalChipSuccess label="Active" />;
|
||||||
|
case 'failed':
|
||||||
|
return <TerminalChipError label="Failed" />;
|
||||||
|
case 'paused':
|
||||||
|
return <TerminalChipWarning label="Paused" />;
|
||||||
|
default:
|
||||||
|
return <TerminalChip label={status} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformIcon = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'gsc':
|
||||||
|
return <Search size={16} />;
|
||||||
|
case 'bing':
|
||||||
|
return <Globe size={16} />;
|
||||||
|
default:
|
||||||
|
return <Info size={16} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformName = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'gsc':
|
||||||
|
return 'Google Search Console';
|
||||||
|
case 'bing':
|
||||||
|
return 'Bing Webmaster Tools';
|
||||||
|
default:
|
||||||
|
return platform.toUpperCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTasks = [
|
||||||
|
...(status?.gsc_tasks || []).map(t => ({ ...t, platform: 'gsc' as const })),
|
||||||
|
...(status?.bing_tasks || []).map(t => ({ ...t, platform: 'bing' as const }))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading && !status) {
|
||||||
|
return (
|
||||||
|
<TerminalPaper>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||||
|
<CircularProgress size={20} sx={{ color: terminalColors.success }} />
|
||||||
|
<TerminalTypography>Loading platform insights tasks...</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<TerminalPaper>
|
||||||
|
<TerminalAlert severity="error" sx={{ m: 2 }}>
|
||||||
|
{error}
|
||||||
|
</TerminalAlert>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status || allTasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<TerminalPaper>
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="body1" sx={{ mb: 1 }}>
|
||||||
|
No platform insights tasks found.
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||||
|
Connect GSC or Bing in onboarding Step 5 to create insights tasks.
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TerminalPaper>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Search size={20} color={terminalColors.primary} />
|
||||||
|
<TerminalTypography variant="h6">
|
||||||
|
Platform Insights Tasks
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalChip label={`${allTasks.length} tasks`} />
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
onClick={fetchStatus}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{
|
||||||
|
color: terminalColors.primary,
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: terminalColors.backgroundHover,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: terminalColors.border, mb: 2 }} />
|
||||||
|
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TerminalTableCell sx={{ width: '5%', fontSize: '0.75rem' }} />
|
||||||
|
<TerminalTableCell sx={{ width: '15%', fontSize: '0.75rem' }}>Platform</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '30%', fontSize: '0.75rem' }}>Site URL</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '15%', fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '35%', fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{allTasks.map((task) => {
|
||||||
|
const isExpanded = expandedTaskId === task.id;
|
||||||
|
const logs = taskLogs[task.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={task.id}>
|
||||||
|
<TerminalTableRow
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: terminalColors.backgroundHover,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => handleToggleExpand(task.id)}
|
||||||
|
>
|
||||||
|
<TerminalTableCell sx={{ width: '5%' }}>
|
||||||
|
<IconButton size="small" sx={{ color: terminalColors.primary }}>
|
||||||
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '15%' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{getPlatformIcon(task.platform)}
|
||||||
|
<Typography sx={{ fontFamily: 'inherit', color: terminalColors.text, fontSize: '0.875rem' }}>
|
||||||
|
{getPlatformName(task.platform)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '30%' }}>
|
||||||
|
{task.site_url ? (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
color: terminalColors.textSecondary,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.site_url}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ fontFamily: 'inherit', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Default site
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '15%' }}>
|
||||||
|
{getStatusChip(task.status)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ width: '35%' }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{task.last_success && (
|
||||||
|
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||||
|
<Chip
|
||||||
|
label="Success"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
color: terminalColors.success,
|
||||||
|
border: `1px solid ${terminalColors.success}`,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 20,
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{task.next_check && (
|
||||||
|
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||||
|
<Chip
|
||||||
|
label="Scheduled"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
color: terminalColors.info,
|
||||||
|
border: `1px solid ${terminalColors.info}`,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 20,
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
</TerminalTableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} sx={{ py: 0, border: 0 }}>
|
||||||
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ p: 2, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||||
|
{task.failure_reason && (
|
||||||
|
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Error: {task.failure_reason}
|
||||||
|
</TerminalAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TerminalTypography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Execution Logs
|
||||||
|
</TerminalTypography>
|
||||||
|
|
||||||
|
{logs?.loading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 2 }}>
|
||||||
|
<CircularProgress size={16} sx={{ color: terminalColors.success }} />
|
||||||
|
<TerminalTypography variant="body2">Loading logs...</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
) : logs?.error ? (
|
||||||
|
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{logs.error}
|
||||||
|
</TerminalAlert>
|
||||||
|
) : logs?.logs && logs.logs.length > 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TerminalTableCell>Date</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Status</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Source</TerminalTableCell>
|
||||||
|
<TerminalTableCell>Duration</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{logs.logs.map((log) => (
|
||||||
|
<React.Fragment key={log.id}>
|
||||||
|
<TerminalTableRow
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: terminalColors.backgroundHover,
|
||||||
|
},
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||||
|
onMouseLeave={() => setHoveredLogId(null)}
|
||||||
|
>
|
||||||
|
<TerminalTableCell>
|
||||||
|
{formatDate(log.execution_date)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
{log.status === 'success' ? (
|
||||||
|
<TerminalChipSuccess label="Success" />
|
||||||
|
) : log.status === 'failed' ? (
|
||||||
|
<TerminalChipError label="Failed" />
|
||||||
|
) : (
|
||||||
|
<TerminalChip label={log.status} />
|
||||||
|
)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<Chip
|
||||||
|
label={log.data_source || 'N/A'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
color: terminalColors.textSecondary,
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 18,
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
{formatDuration(log.execution_time_ms)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
</TerminalTableRow>
|
||||||
|
{hoveredLogId === log.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ py: 1, border: 0, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||||
|
{log.error_message && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.error, fontWeight: 'bold' }}>
|
||||||
|
Error:
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.text, ml: 1 }}>
|
||||||
|
{log.error_message}
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{log.result_data && (
|
||||||
|
<Box>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.info, fontWeight: 'bold' }}>
|
||||||
|
Result:
|
||||||
|
</TerminalTypography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: terminalColors.textSecondary,
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 150,
|
||||||
|
mt: 0.5,
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(log.result_data, null, 2)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, p: 2 }}>
|
||||||
|
No execution logs yet.
|
||||||
|
</TerminalTypography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformInsightsStatus;
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
ResponsiveContainer
|
ResponsiveContainer
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
import { Box, Paper, CircularProgress, Modal, IconButton } from '@mui/material';
|
||||||
|
import { Close as CloseIcon, OpenInFull as MaximizeIcon } from '@mui/icons-material';
|
||||||
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
|
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
|
||||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||||
|
|
||||||
@@ -25,10 +26,54 @@ interface SchedulerChartsProps {
|
|||||||
events?: SchedulerEvent[];
|
events?: SchedulerEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChartModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartModal: React.FC<ChartModalProps> = ({ open, onClose, title, children }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalPaper
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<TerminalTypography variant="h5" sx={{ color: terminalColors.primary }}>
|
||||||
|
{title}
|
||||||
|
</TerminalTypography>
|
||||||
|
<IconButton onClick={onClose} sx={{ color: terminalColors.primary }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</TerminalPaper>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents }) => {
|
const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents }) => {
|
||||||
const [events, setEvents] = useState<SchedulerEvent[]>(propEvents || []);
|
const [events, setEvents] = useState<SchedulerEvent[]>(propEvents || []);
|
||||||
const [loading, setLoading] = useState(!propEvents);
|
const [loading, setLoading] = useState(!propEvents);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch events if not provided as prop
|
// Fetch events if not provided as prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,10 +82,10 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
// Fetch all events for visualization (no pagination limit)
|
// Fetch events for visualization (max 500 per backend API limit)
|
||||||
// Pass undefined to get all event types
|
// Pass undefined to get all event types, use 30 days for charts
|
||||||
console.log('📊 Charts - Fetching event history...');
|
console.log('📊 Charts - Fetching event history...');
|
||||||
const response = await getSchedulerEventHistory(1000, 0, undefined);
|
const response = await getSchedulerEventHistory(500, 0, undefined, 30);
|
||||||
console.log('📊 Charts - Fetched events:', {
|
console.log('📊 Charts - Fetched events:', {
|
||||||
totalEvents: response.events?.length || 0,
|
totalEvents: response.events?.length || 0,
|
||||||
totalCount: response.total_count,
|
totalCount: response.total_count,
|
||||||
@@ -216,58 +261,172 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleChartClick = (chartId: string) => {
|
||||||
|
setModalOpen(chartId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setModalOpen(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box>
|
||||||
{/* Summary Stats */}
|
{/* Compact Charts in Single Row */}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, overflowX: 'auto', pb: 2 }}>
|
||||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
{/* Task Execution Trends - Compact */}
|
||||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
<Box
|
||||||
{totals.check_cycles}
|
sx={{
|
||||||
</TerminalTypography>
|
flex: '0 0 300px',
|
||||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
cursor: 'pointer',
|
||||||
Check Cycles
|
transition: 'transform 0.2s',
|
||||||
</TerminalTypography>
|
'&:hover': {
|
||||||
</TerminalPaper>
|
transform: 'scale(1.05)',
|
||||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
},
|
||||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
}}
|
||||||
{totals.tasks_executed}
|
onClick={() => handleChartClick('task-execution')}
|
||||||
</TerminalTypography>
|
>
|
||||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||||
Tasks Executed
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
</TerminalTypography>
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||||
</TerminalPaper>
|
Task Execution Trends
|
||||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
</TerminalTypography>
|
||||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||||
{totals.tasks_failed}
|
</Box>
|
||||||
</TerminalTypography>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
<LineChart data={chartData.slice(-7)}>
|
||||||
Tasks Failed
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
</TerminalTypography>
|
<XAxis
|
||||||
</TerminalPaper>
|
dataKey="date"
|
||||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
stroke={terminalColors.primary}
|
||||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
{totals.job_completed}
|
interval="preserveStartEnd"
|
||||||
</TerminalTypography>
|
/>
|
||||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
<YAxis
|
||||||
Jobs Completed
|
stroke={terminalColors.primary}
|
||||||
</TerminalTypography>
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
</TerminalPaper>
|
width={30}
|
||||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
/>
|
||||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
<Tooltip content={<CustomTooltip />} />
|
||||||
{totals.job_failed}
|
<Line
|
||||||
</TerminalTypography>
|
type="monotone"
|
||||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
dataKey="tasks_executed"
|
||||||
Jobs Failed
|
stroke={terminalColors.success}
|
||||||
</TerminalTypography>
|
strokeWidth={2}
|
||||||
</TerminalPaper>
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="tasks_failed"
|
||||||
|
stroke={terminalColors.error}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TerminalPaper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Job Status Distribution - Compact */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: '0 0 300px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => handleChartClick('job-status')}
|
||||||
|
>
|
||||||
|
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||||
|
Job Status Distribution
|
||||||
|
</TerminalTypography>
|
||||||
|
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||||
|
</Box>
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<BarChart data={chartData.slice(-7)}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke={terminalColors.primary}
|
||||||
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke={terminalColors.primary}
|
||||||
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="job_completed"
|
||||||
|
fill={terminalColors.success}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="job_failed"
|
||||||
|
fill={terminalColors.error}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TerminalPaper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Check Cycles - Compact */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: '0 0 300px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => handleChartClick('check-cycles')}
|
||||||
|
>
|
||||||
|
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||||
|
Check Cycles Over Time
|
||||||
|
</TerminalTypography>
|
||||||
|
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||||
|
</Box>
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<BarChart data={chartData.slice(-7)}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke={terminalColors.primary}
|
||||||
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke={terminalColors.primary}
|
||||||
|
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="check_cycles"
|
||||||
|
fill={terminalColors.primary}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TerminalPaper>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Task Execution Trends */}
|
{/* Modals for Expanded Charts */}
|
||||||
<TerminalPaper sx={{ p: 3 }}>
|
<ChartModal
|
||||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
open={modalOpen === 'task-execution'}
|
||||||
Task Execution Trends (Last 30 Days)
|
onClose={handleModalClose}
|
||||||
</TerminalTypography>
|
title="Task Execution Trends (Last 30 Days)"
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -309,14 +468,14 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
|||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</TerminalPaper>
|
</ChartModal>
|
||||||
|
|
||||||
{/* Job Status Distribution */}
|
<ChartModal
|
||||||
<TerminalPaper sx={{ p: 3 }}>
|
open={modalOpen === 'job-status'}
|
||||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
onClose={handleModalClose}
|
||||||
Job Status Distribution (Last 30 Days)
|
title="Job Status Distribution (Last 30 Days)"
|
||||||
</TerminalTypography>
|
>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -349,14 +508,14 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
|||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</TerminalPaper>
|
</ChartModal>
|
||||||
|
|
||||||
{/* Check Cycles Over Time */}
|
<ChartModal
|
||||||
<TerminalPaper sx={{ p: 3 }}>
|
open={modalOpen === 'check-cycles'}
|
||||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
onClose={handleModalClose}
|
||||||
Check Cycles Over Time (Last 30 Days)
|
title="Check Cycles Over Time (Last 30 Days)"
|
||||||
</TerminalTypography>
|
>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -376,7 +535,51 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
|||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</TerminalPaper>
|
</ChartModal>
|
||||||
|
|
||||||
|
{/* Summary Stats - Compact */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 2, mt: 2 }}>
|
||||||
|
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||||
|
{totals.check_cycles}
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Check Cycles
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalPaper>
|
||||||
|
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||||
|
{totals.tasks_executed}
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Tasks Executed
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalPaper>
|
||||||
|
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||||
|
{totals.tasks_failed}
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Tasks Failed
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalPaper>
|
||||||
|
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||||
|
{totals.job_completed}
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Jobs Completed
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalPaper>
|
||||||
|
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||||
|
{totals.job_failed}
|
||||||
|
</TerminalTypography>
|
||||||
|
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||||
|
Jobs Failed
|
||||||
|
</TerminalTypography>
|
||||||
|
</TerminalPaper>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,14 +37,16 @@ interface SchedulerEventHistoryProps {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 50 }) => {
|
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = () => {
|
||||||
const [events, setEvents] = useState<SchedulerEvent[]>([]);
|
const [events, setEvents] = useState<SchedulerEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(limit);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(5); // Start with 5, expand to 50 on hover
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
|
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
|
||||||
|
const [daysFilter, setDaysFilter] = useState<number>(7);
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const fetchEvents = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +56,8 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
|||||||
const response = await getSchedulerEventHistory(
|
const response = await getSchedulerEventHistory(
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
page * rowsPerPage,
|
page * rowsPerPage,
|
||||||
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined
|
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined,
|
||||||
|
daysFilter
|
||||||
);
|
);
|
||||||
|
|
||||||
setEvents(response.events);
|
setEvents(response.events);
|
||||||
@@ -70,7 +73,16 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page, rowsPerPage, eventTypeFilter]); // fetchEvents is stable, no need to include
|
}, [page, rowsPerPage, eventTypeFilter, daysFilter]); // fetchEvents is stable, no need to include
|
||||||
|
|
||||||
|
// Expand to 50 rows on hover
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
setRowsPerPage(50);
|
||||||
|
setPage(0); // Reset to first page when expanding
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
@@ -169,40 +181,92 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalPaper>
|
<TerminalPaper
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
sx={{
|
||||||
|
cursor: isExpanded ? 'default' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: isExpanded ? undefined : '0 4px 8px rgba(0,0,0,0.2)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box p={2}>
|
<Box p={2}>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2} flexWrap="wrap" gap={2}>
|
||||||
<TerminalTypography variant="h6">
|
<TerminalTypography variant="h6">
|
||||||
📜 Scheduler Event History
|
📜 Scheduler Event History
|
||||||
|
{!isExpanded && (
|
||||||
|
<Tooltip title="Hover to expand and see more events with pagination">
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: terminalColors.info,
|
||||||
|
ml: 1,
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
(Hover to expand)
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</TerminalTypography>
|
</TerminalTypography>
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
<Box display="flex" gap={2} flexWrap="wrap">
|
||||||
<InputLabel sx={{ color: terminalColors.primary }}>Event Type</InputLabel>
|
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||||
<Select
|
<InputLabel sx={{ color: terminalColors.primary }}>Days</InputLabel>
|
||||||
value={eventTypeFilter}
|
<Select
|
||||||
onChange={(e) => {
|
value={daysFilter}
|
||||||
setEventTypeFilter(e.target.value);
|
onChange={(e) => {
|
||||||
setPage(0);
|
setDaysFilter(e.target.value as number);
|
||||||
}}
|
setPage(0);
|
||||||
sx={{
|
}}
|
||||||
color: terminalColors.primary,
|
sx={{
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
|
||||||
borderColor: terminalColors.primary,
|
|
||||||
},
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
color: terminalColors.primary,
|
color: terminalColors.primary,
|
||||||
}
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
}}
|
borderColor: terminalColors.primary,
|
||||||
>
|
},
|
||||||
<MenuItem value="all">All Events</MenuItem>
|
'& .MuiSvgIcon-root': {
|
||||||
<MenuItem value="check_cycle">Check Cycles</MenuItem>
|
color: terminalColors.primary,
|
||||||
<MenuItem value="interval_adjustment">Interval Adjustments</MenuItem>
|
}
|
||||||
<MenuItem value="start">Scheduler Start</MenuItem>
|
}}
|
||||||
<MenuItem value="stop">Scheduler Stop</MenuItem>
|
>
|
||||||
<MenuItem value="job_scheduled">Job Scheduled</MenuItem>
|
<MenuItem value={1}>Last 1 day</MenuItem>
|
||||||
<MenuItem value="job_completed">Job Completed</MenuItem>
|
<MenuItem value={3}>Last 3 days</MenuItem>
|
||||||
<MenuItem value="job_failed">Job Failed</MenuItem>
|
<MenuItem value={7}>Last 7 days</MenuItem>
|
||||||
</Select>
|
<MenuItem value={14}>Last 14 days</MenuItem>
|
||||||
</FormControl>
|
<MenuItem value={30}>Last 30 days</MenuItem>
|
||||||
|
<MenuItem value={90}>Last 90 days</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel sx={{ color: terminalColors.primary }}>Event Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={eventTypeFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEventTypeFilter(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
color: terminalColors.primary,
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: terminalColors.primary,
|
||||||
|
},
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
color: terminalColors.primary,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Events</MenuItem>
|
||||||
|
<MenuItem value="check_cycle">Check Cycles</MenuItem>
|
||||||
|
<MenuItem value="interval_adjustment">Interval Adjustments</MenuItem>
|
||||||
|
<MenuItem value="start">Scheduler Start</MenuItem>
|
||||||
|
<MenuItem value="stop">Scheduler Stop</MenuItem>
|
||||||
|
<MenuItem value="job_scheduled">Job Scheduled</MenuItem>
|
||||||
|
<MenuItem value="job_completed">Job Completed</MenuItem>
|
||||||
|
<MenuItem value="job_failed">Job Failed</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
@@ -284,24 +348,33 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
||||||
<TablePagination
|
{isExpanded && (
|
||||||
component="div"
|
<TablePagination
|
||||||
count={totalCount}
|
component="div"
|
||||||
page={page}
|
count={totalCount}
|
||||||
onPageChange={handleChangePage}
|
page={page}
|
||||||
rowsPerPage={rowsPerPage}
|
onPageChange={handleChangePage}
|
||||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
sx={{
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
color: terminalColors.primary,
|
sx={{
|
||||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
|
||||||
color: terminalColors.primary,
|
color: terminalColors.primary,
|
||||||
},
|
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||||
'& .MuiIconButton-root': {
|
color: terminalColors.primary,
|
||||||
color: terminalColors.primary,
|
},
|
||||||
}
|
'& .MuiIconButton-root': {
|
||||||
}}
|
color: terminalColors.primary,
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isExpanded && totalCount > events.length && (
|
||||||
|
<Box p={2} textAlign="center">
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.info, fontStyle: 'italic' }}>
|
||||||
|
Showing {events.length} of {totalCount} events. Hover to expand and see more with pagination.
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Displays scheduled jobs in tree structure matching log format.
|
* Displays scheduled jobs in tree structure matching log format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Schedule as ScheduleIcon,
|
Schedule as ScheduleIcon,
|
||||||
@@ -26,6 +26,34 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
|||||||
recurringJobs,
|
recurringJobs,
|
||||||
oneTimeJobs
|
oneTimeJobs
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const DEFAULT_DISPLAY_COUNT = 3; // Show only 3 jobs by default
|
||||||
|
const COLLAPSE_DELAY = 2000; // 2 seconds delay before collapsing
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setIsExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
}, COLLAPSE_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayedJobs = isExpanded ? jobs : jobs.slice(0, DEFAULT_DISPLAY_COUNT);
|
||||||
|
const hasMoreJobs = jobs.length > DEFAULT_DISPLAY_COUNT;
|
||||||
const formatDate = (dateString: string | null) => {
|
const formatDate = (dateString: string | null) => {
|
||||||
if (!dateString) return 'Not scheduled';
|
if (!dateString) return 'Not scheduled';
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +94,26 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
|||||||
};
|
};
|
||||||
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
|
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
if (jobId.includes('website_analysis')) {
|
||||||
|
// Extract task type from job
|
||||||
|
const taskType = job?.task_type || 'Website';
|
||||||
|
const taskTypeNames: { [key: string]: string } = {
|
||||||
|
'user_website': 'User Website',
|
||||||
|
'competitor': 'Competitor'
|
||||||
|
};
|
||||||
|
return `Website Analysis - ${taskTypeNames[taskType] || taskType}`;
|
||||||
|
}
|
||||||
|
if (jobId.includes('platform_insights')) {
|
||||||
|
// Extract platform from job ID or use platform field
|
||||||
|
const platform = job?.platform ||
|
||||||
|
jobId.split('_')[2] ||
|
||||||
|
'Platform';
|
||||||
|
const platformNames: { [key: string]: string } = {
|
||||||
|
'gsc': 'GSC Insights',
|
||||||
|
'bing': 'Bing Insights'
|
||||||
|
};
|
||||||
|
return platformNames[platform] || `${platform.toUpperCase()} Insights`;
|
||||||
|
}
|
||||||
return 'One-Time';
|
return 'One-Time';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,19 +141,38 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.text, flex: 1, overflow: 'auto', minHeight: 0 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: terminalColors.text,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box mb={2} sx={{ flexShrink: 0 }}>
|
<Box mb={2} sx={{ flexShrink: 0 }}>
|
||||||
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
|
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
|
||||||
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
|
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
|
||||||
|
{hasMoreJobs && !isExpanded && (
|
||||||
|
<TerminalTypography
|
||||||
|
component="span"
|
||||||
|
sx={{ ml: 1, color: terminalColors.primary, fontStyle: 'italic', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
(Hover to see all {jobs.length} jobs)
|
||||||
|
</TerminalTypography>
|
||||||
|
)}
|
||||||
</TerminalTypography>
|
</TerminalTypography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Jobs Tree */}
|
{/* Jobs Tree */}
|
||||||
{jobs.length > 0 ? (
|
{displayedJobs.length > 0 ? (
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
{jobs.map((job, index) => {
|
{displayedJobs.map((job, index) => {
|
||||||
const isLast = index === jobs.length - 1;
|
const isLast = index === displayedJobs.length - 1 && (!hasMoreJobs || isExpanded);
|
||||||
const prefix = isLast ? '└─' : '├─';
|
const prefix = isLast ? '└─' : '├─';
|
||||||
const isRecurring = job.id === 'check_due_tasks';
|
const isRecurring = job.id === 'check_due_tasks';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Task Monitoring Tabs Component
|
||||||
|
* Organizes OAuth Token Status, Website Analysis Status, and Platform Insights in tabs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Tabs, Tab } from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import OAuthTokenStatus from './OAuthTokenStatus';
|
||||||
|
import WebsiteAnalysisStatus from './WebsiteAnalysisStatus';
|
||||||
|
import PlatformInsightsStatus from './PlatformInsightsStatus';
|
||||||
|
import { TerminalPaper, terminalColors } from './terminalTheme';
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`task-monitoring-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`task-monitoring-tab-${index}`}
|
||||||
|
>
|
||||||
|
<Box sx={{ pt: 3, display: value === index ? 'block' : 'none' }}>{children}</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terminal-themed button-like tab styling
|
||||||
|
const TerminalTab = styled(Tab)({
|
||||||
|
minHeight: 48,
|
||||||
|
padding: '8px 16px',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: terminalColors.textSecondary,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
borderRadius: '4px 4px 0 0',
|
||||||
|
marginRight: '4px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: terminalColors.backgroundHover,
|
||||||
|
color: terminalColors.primary,
|
||||||
|
borderColor: terminalColors.primary,
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: terminalColors.primary,
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
borderColor: terminalColors.primary,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
'&:focus': {
|
||||||
|
outline: `2px solid ${terminalColors.primary}`,
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TaskMonitoringTabs: React.FC = () => {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
|
||||||
|
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TerminalPaper sx={{ p: 0 }}>
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: terminalColors.border, px: 2, pt: 2 }}>
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label="task monitoring tabs"
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
'& .MuiTabs-indicator': {
|
||||||
|
display: 'none', // Hide default indicator, we use border styling instead
|
||||||
|
},
|
||||||
|
'& .MuiTabs-flexContainer': {
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalTab
|
||||||
|
label="OAuth Token Status"
|
||||||
|
id="task-monitoring-tab-0"
|
||||||
|
aria-controls="task-monitoring-tabpanel-0"
|
||||||
|
/>
|
||||||
|
<TerminalTab
|
||||||
|
label="Website Analysis"
|
||||||
|
id="task-monitoring-tab-1"
|
||||||
|
aria-controls="task-monitoring-tabpanel-1"
|
||||||
|
/>
|
||||||
|
<TerminalTab
|
||||||
|
label="Platform Insights"
|
||||||
|
id="task-monitoring-tab-2"
|
||||||
|
aria-controls="task-monitoring-tabpanel-2"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
<TabPanel value={value} index={0}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<OAuthTokenStatus compact={true} />
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value={value} index={1}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<WebsiteAnalysisStatus compact={true} />
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value={value} index={2}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<PlatformInsightsStatus compact={true} />
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskMonitoringTabs;
|
||||||
|
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
/**
|
||||||
|
* Website Analysis Status Component
|
||||||
|
* Compact terminal-themed component for displaying website analysis task status
|
||||||
|
* with execution logs in expanded sections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
import {
|
||||||
|
getWebsiteAnalysisStatus,
|
||||||
|
retryWebsiteAnalysis,
|
||||||
|
getWebsiteAnalysisLogs,
|
||||||
|
WebsiteAnalysisStatusResponse,
|
||||||
|
WebsiteAnalysisTask,
|
||||||
|
WebsiteAnalysisExecutionLog,
|
||||||
|
WebsiteAnalysisLogsResponse,
|
||||||
|
} from '../../api/websiteAnalysisMonitoring';
|
||||||
|
import {
|
||||||
|
TerminalPaper,
|
||||||
|
TerminalTypography,
|
||||||
|
TerminalChip,
|
||||||
|
TerminalChipSuccess,
|
||||||
|
TerminalChipError,
|
||||||
|
TerminalChipWarning,
|
||||||
|
TerminalAlert,
|
||||||
|
TerminalTableCell,
|
||||||
|
TerminalTableRow,
|
||||||
|
terminalColors,
|
||||||
|
} from './terminalTheme';
|
||||||
|
|
||||||
|
interface WebsiteAnalysisStatusProps {
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskLogs {
|
||||||
|
[taskId: number]: {
|
||||||
|
logs: WebsiteAnalysisExecutionLog[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebsiteAnalysisStatus: React.FC<WebsiteAnalysisStatusProps> = ({ compact = true }) => {
|
||||||
|
const { userId } = useAuth();
|
||||||
|
const [status, setStatus] = useState<WebsiteAnalysisStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedTaskId, setExpandedTaskId] = useState<number | null>(null);
|
||||||
|
const [taskLogs, setTaskLogs] = useState<TaskLogs>({});
|
||||||
|
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await getWebsiteAnalysisStatus(userId);
|
||||||
|
setStatus(response);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch website analysis status');
|
||||||
|
console.error('Error fetching website analysis status:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTaskLogs = async (taskId: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Initialize task logs state if not exists
|
||||||
|
if (!taskLogs[taskId]) {
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { logs: [], loading: false, error: null }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already loading
|
||||||
|
if (taskLogs[taskId]?.loading) return;
|
||||||
|
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { ...prev[taskId], loading: true, error: null }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[WebsiteAnalysis] Fetching logs for task ${taskId}...`);
|
||||||
|
const response = await getWebsiteAnalysisLogs(userId, 10, 0, taskId);
|
||||||
|
console.log(`[WebsiteAnalysis] Received logs response:`, {
|
||||||
|
logsCount: response.logs?.length || 0,
|
||||||
|
totalCount: response.total_count,
|
||||||
|
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||||
|
firstLog: response.logs?.[0] || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.logs && Array.isArray(response.logs)) {
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { logs: response.logs, loading: false, error: null }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebsiteAnalysis] Invalid logs response structure:`, response);
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { logs: [], loading: false, error: 'Invalid response structure' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[WebsiteAnalysis] Error fetching logs for task ${taskId}:`, err);
|
||||||
|
setTaskLogs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[taskId]: { ...prev[taskId], loading: false, error: err.message || 'Failed to fetch logs' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (taskId: number) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRefreshing(taskId);
|
||||||
|
await retryWebsiteAnalysis(taskId);
|
||||||
|
await fetchStatus(); // Refresh status
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error retrying website analysis:', err);
|
||||||
|
alert(err.message || 'Failed to retry website analysis');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleExpand = (taskId: number) => {
|
||||||
|
if (expandedTaskId === taskId) {
|
||||||
|
setExpandedTaskId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedTaskId(taskId);
|
||||||
|
// Always fetch logs when expanding to get latest data
|
||||||
|
fetchTaskLogs(taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
// Refresh every 5 minutes (same as other dashboard components)
|
||||||
|
// Tasks run on schedule (every 10 days for competitors, etc.), so frequent polling is unnecessary
|
||||||
|
const interval = setInterval(fetchStatus, 5 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Fetch logs when task is expanded (similar to OAuth pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedTaskId && userId) {
|
||||||
|
fetchTaskLogs(expandedTaskId);
|
||||||
|
}
|
||||||
|
}, [expandedTaskId, userId]);
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return <CheckCircle size={16} color={terminalColors.success} />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle size={16} color={terminalColors.error} />;
|
||||||
|
case 'paused':
|
||||||
|
return <AlertTriangle size={16} color={terminalColors.warning} />;
|
||||||
|
default:
|
||||||
|
return <Info size={16} color={terminalColors.primary} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = (taskStatus: string) => {
|
||||||
|
switch (taskStatus) {
|
||||||
|
case 'active':
|
||||||
|
return <TerminalChipSuccess label="Active" size="small" />;
|
||||||
|
case 'failed':
|
||||||
|
return <TerminalChipError label="Failed" size="small" />;
|
||||||
|
case 'paused':
|
||||||
|
return <TerminalChipWarning label="Paused" size="small" />;
|
||||||
|
default:
|
||||||
|
return <TerminalChip label={taskStatus} size="small" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLogStatusChip = (logStatus: string) => {
|
||||||
|
switch (logStatus) {
|
||||||
|
case 'success':
|
||||||
|
return <TerminalChipSuccess label="Success" size="small" />;
|
||||||
|
case 'failed':
|
||||||
|
return <TerminalChipError label="Failed" size="small" />;
|
||||||
|
case 'running':
|
||||||
|
return <TerminalChipWarning label="Running" size="small" />;
|
||||||
|
default:
|
||||||
|
return <Chip label={logStatus} size="small" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLogResult = (resultData: any): string => {
|
||||||
|
if (!resultData) return 'N/A';
|
||||||
|
if (typeof resultData === 'string') {
|
||||||
|
try {
|
||||||
|
resultData = JSON.parse(resultData);
|
||||||
|
} catch {
|
||||||
|
return resultData.substring(0, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultData.style_analysis) {
|
||||||
|
return 'Analysis completed';
|
||||||
|
}
|
||||||
|
if (resultData.crawl_result) {
|
||||||
|
return 'Crawl completed';
|
||||||
|
}
|
||||||
|
const str = JSON.stringify(resultData);
|
||||||
|
return str.length > 60 ? str.substring(0, 60) + '...' : str;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !status) {
|
||||||
|
return (
|
||||||
|
<TerminalPaper sx={{ p: 2 }}>
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" p={2}>
|
||||||
|
<CircularProgress size={20} sx={{ color: terminalColors.primary }} />
|
||||||
|
</Box>
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTasks = [
|
||||||
|
...status.data.user_website_tasks,
|
||||||
|
...status.data.competitor_tasks
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderTaskRow = (task: WebsiteAnalysisTask) => {
|
||||||
|
const isExpanded = expandedTaskId === task.id;
|
||||||
|
const logs = taskLogs[task.id]?.logs || [];
|
||||||
|
const logsLoading = taskLogs[task.id]?.loading || false;
|
||||||
|
const logsError = taskLogs[task.id]?.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={task.id}>
|
||||||
|
<TerminalTableRow
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { backgroundColor: terminalColors.backgroundHover }
|
||||||
|
}}
|
||||||
|
onClick={() => handleToggleExpand(task.id)}
|
||||||
|
>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{task.task_type === 'user_website' ? (
|
||||||
|
<Globe size={16} color={terminalColors.primary} />
|
||||||
|
) : (
|
||||||
|
<Users size={16} color={terminalColors.secondary} />
|
||||||
|
)}
|
||||||
|
<TerminalTypography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{task.website_url}
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
{getStatusChip(task.status)}
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<Box display="flex" alignItems="center" gap={0.5} flexWrap="wrap">
|
||||||
|
{task.last_success && (
|
||||||
|
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||||
|
<Chip
|
||||||
|
label={`Last: ${formatDate(task.last_success).split(',')[0]}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{task.next_check && (
|
||||||
|
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||||
|
<Chip
|
||||||
|
label={`Next: ${formatDate(task.next_check).split(',')[0]}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{task.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRetry(task.id);
|
||||||
|
}}
|
||||||
|
disabled={refreshing === task.id}
|
||||||
|
sx={{
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
borderColor: terminalColors.border,
|
||||||
|
color: terminalColors.text,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: terminalColors.primary,
|
||||||
|
backgroundColor: terminalColors.backgroundHover,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{refreshing === task.id ? <CircularProgress size={12} /> : 'Retry'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleExpand(task.id);
|
||||||
|
}}
|
||||||
|
sx={{ color: terminalColors.text }}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</TerminalTableCell>
|
||||||
|
</TerminalTableRow>
|
||||||
|
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ py: 0, border: 0 }}>
|
||||||
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ p: 2, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||||
|
{task.failure_reason && (
|
||||||
|
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Error: {task.failure_reason}
|
||||||
|
</TerminalAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ mb: 1, color: terminalColors.text, fontSize: '0.9rem' }}>
|
||||||
|
Monitoring Logs
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{logsLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center" p={2}>
|
||||||
|
<CircularProgress size={16} sx={{ color: terminalColors.primary }} />
|
||||||
|
</Box>
|
||||||
|
) : logsError ? (
|
||||||
|
<TerminalAlert severity="error">{logsError}</TerminalAlert>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||||
|
No execution logs yet
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${terminalColors.border}`,
|
||||||
|
borderRadius: 1,
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '8px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: terminalColors.background,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: terminalColors.border,
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||||
|
Date
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||||
|
Status
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||||
|
Result
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||||
|
Duration
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<React.Fragment key={log.id}>
|
||||||
|
<TableRow
|
||||||
|
sx={{
|
||||||
|
'&:hover': { backgroundColor: terminalColors.backgroundHover },
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||||
|
onMouseLeave={() => setHoveredLogId(null)}
|
||||||
|
>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{formatDate(log.execution_date)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{getLogStatusChip(log.status)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{formatLogResult(log.result_data)}
|
||||||
|
</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||||
|
{log.execution_time_ms ? `${log.execution_time_ms}ms` : 'N/A'}
|
||||||
|
</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
{hoveredLogId === log.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ py: 1, border: 0, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||||
|
{log.error_message && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: terminalColors.error, fontWeight: 'bold' }}>
|
||||||
|
Error:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: terminalColors.text, display: 'block', ml: 1 }}>
|
||||||
|
{log.error_message}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{log.result_data && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: terminalColors.textSecondary, fontWeight: 'bold' }}>
|
||||||
|
Result Data:
|
||||||
|
</Typography>
|
||||||
|
<pre style={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: terminalColors.text,
|
||||||
|
margin: '4px 0 0 0',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(log.result_data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TerminalPaper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<TerminalTypography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Globe size={20} />
|
||||||
|
Website Analysis Status
|
||||||
|
</TerminalTypography>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{status && (
|
||||||
|
<TerminalChip
|
||||||
|
label={`${status.data.active_tasks} Active`}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status && status.data.failed_tasks > 0 && (
|
||||||
|
<TerminalChipError
|
||||||
|
label={`${status.data.failed_tasks} Failed`}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={fetchStatus}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ color: terminalColors.text }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</TerminalAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
|
{status.data.user_website_tasks.length > 0 && (
|
||||||
|
<Box mb={2}>
|
||||||
|
<TerminalTypography variant="subtitle2" sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Globe size={14} />
|
||||||
|
User Website ({status.data.user_website_tasks.length})
|
||||||
|
</TerminalTypography>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Website</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Actions</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{status.data.user_website_tasks.map(renderTaskRow)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.data.competitor_tasks.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<TerminalTypography variant="subtitle2" sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Users size={14} />
|
||||||
|
Competitors ({status.data.competitor_tasks.length})
|
||||||
|
</TerminalTypography>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Website</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||||
|
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Actions</TerminalTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{status.data.competitor_tasks.map(renderTaskRow)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allTasks.length === 0 && (
|
||||||
|
<Box p={2} textAlign="center">
|
||||||
|
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||||
|
No website analysis tasks found. Complete onboarding to create tasks.
|
||||||
|
</TerminalTypography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TerminalPaper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebsiteAnalysisStatus;
|
||||||
|
|
||||||
@@ -180,6 +180,8 @@ export const terminalColors = {
|
|||||||
success: '#00ff00',
|
success: '#00ff00',
|
||||||
background: '#0a0a0a',
|
background: '#0a0a0a',
|
||||||
backgroundLight: '#1a1a1a',
|
backgroundLight: '#1a1a1a',
|
||||||
|
backgroundHover: 'rgba(0, 255, 0, 0.05)',
|
||||||
|
backgroundSecondary: 'rgba(0, 255, 0, 0.05)',
|
||||||
text: '#00ff00',
|
text: '#00ff00',
|
||||||
textSecondary: '#00ff88',
|
textSecondary: '#00ff88',
|
||||||
border: '#00ff00',
|
border: '#00ff00',
|
||||||
|
|||||||
@@ -229,10 +229,15 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
|
|||||||
{errorData.usage_info.current_calls?.toLocaleString() || 0}
|
{errorData.usage_info.current_calls?.toLocaleString() || 0}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: '#7f1d1d' }}>
|
<Typography variant="body2" sx={{ color: '#7f1d1d' }}>
|
||||||
/ {errorData.usage_info.call_limit?.toLocaleString() || 0}
|
/ {(errorData.usage_info.limit || errorData.usage_info.call_limit || 0)?.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: '#7f1d1d', ml: 'auto' }}>
|
<Typography variant="caption" sx={{ color: '#7f1d1d', ml: 'auto' }}>
|
||||||
({((errorData.usage_info.current_calls / errorData.usage_info.call_limit) * 100).toFixed(1)}% used)
|
{(() => {
|
||||||
|
const limit = errorData.usage_info.limit || errorData.usage_info.call_limit || 0;
|
||||||
|
const current = errorData.usage_info.current_calls || 0;
|
||||||
|
const percentage = limit > 0 ? ((current / limit) * 100).toFixed(1) : '0.0';
|
||||||
|
return `(${percentage}% used)`;
|
||||||
|
})()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ResearchWizard } from '../components/Research';
|
import { ResearchWizard } from '../components/Research';
|
||||||
import { BlogResearchResponse } from '../services/blogWriterApi';
|
import { BlogResearchResponse } from '../services/blogWriterApi';
|
||||||
import { getResearchConfig, PersonaDefaults, refreshResearchPersona, ResearchPersona } from '../api/researchConfig';
|
import { getResearchConfig, PersonaDefaults, refreshResearchPersona, ResearchPersona, getCompetitorAnalysis, CompetitorAnalysisResponse } from '../api/researchConfig';
|
||||||
import { ResearchPersonaModal } from '../components/Research/ResearchPersonaModal';
|
import { ResearchPersonaModal } from '../components/Research/ResearchPersonaModal';
|
||||||
|
import { OnboardingCompetitorModal } from '../components/Research/OnboardingCompetitorModal';
|
||||||
|
|
||||||
const samplePresets = [
|
const samplePresets = [
|
||||||
{
|
{
|
||||||
@@ -204,6 +205,13 @@ export const ResearchTest: React.FC = () => {
|
|||||||
const [showPersonaModal, setShowPersonaModal] = useState(false);
|
const [showPersonaModal, setShowPersonaModal] = useState(false);
|
||||||
const [personaChecked, setPersonaChecked] = useState(false);
|
const [personaChecked, setPersonaChecked] = useState(false);
|
||||||
const [researchPersona, setResearchPersona] = useState<ResearchPersona | null>(null);
|
const [researchPersona, setResearchPersona] = useState<ResearchPersona | null>(null);
|
||||||
|
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
|
||||||
|
const [competitorData, setCompetitorData] = useState<CompetitorAnalysisResponse | null>(null);
|
||||||
|
const [loadingCompetitors, setLoadingCompetitors] = useState(false);
|
||||||
|
const [competitorError, setCompetitorError] = useState<string | null>(null);
|
||||||
|
const [showPersonaDetailsModal, setShowPersonaDetailsModal] = useState(false);
|
||||||
|
const [personaExists, setPersonaExists] = useState(false);
|
||||||
|
const [loadingPersonaDetails, setLoadingPersonaDetails] = useState(false);
|
||||||
|
|
||||||
// Debug: Track modal state changes
|
// Debug: Track modal state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,6 +244,7 @@ export const ResearchTest: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setResearchPersona(config.research_persona);
|
setResearchPersona(config.research_persona);
|
||||||
|
setPersonaExists(true);
|
||||||
|
|
||||||
// Use AI-generated presets if persona exists
|
// Use AI-generated presets if persona exists
|
||||||
if (config.research_persona.recommended_presets && config.research_persona.recommended_presets.length > 0) {
|
if (config.research_persona.recommended_presets && config.research_persona.recommended_presets.length > 0) {
|
||||||
@@ -243,7 +252,11 @@ export const ResearchTest: React.FC = () => {
|
|||||||
// Convert AI presets to display format
|
// Convert AI presets to display format
|
||||||
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
keywords: preset.keywords.join(', '),
|
keywords: typeof preset.keywords === 'string'
|
||||||
|
? preset.keywords
|
||||||
|
: Array.isArray(preset.keywords)
|
||||||
|
? preset.keywords.join(', ')
|
||||||
|
: 'N/A',
|
||||||
industry: config.persona_defaults?.industry || 'General',
|
industry: config.persona_defaults?.industry || 'General',
|
||||||
targetAudience: config.persona_defaults?.target_audience || 'General',
|
targetAudience: config.persona_defaults?.target_audience || 'General',
|
||||||
researchMode: preset.config?.mode || 'comprehensive',
|
researchMode: preset.config?.mode || 'comprehensive',
|
||||||
@@ -268,21 +281,23 @@ export const ResearchTest: React.FC = () => {
|
|||||||
const dynamicPresets = generatePersonaPresets(config.persona_defaults || null);
|
const dynamicPresets = generatePersonaPresets(config.persona_defaults || null);
|
||||||
setDisplayPresets(dynamicPresets);
|
setDisplayPresets(dynamicPresets);
|
||||||
|
|
||||||
// Show modal only if onboarding is completed
|
// Show modal when research persona is missing
|
||||||
|
// This allows users to generate a research persona even if onboarding isn't completed yet
|
||||||
|
// or if the cached persona has expired
|
||||||
|
console.log('[ResearchTest] ✅ Research persona missing - SHOWING MODAL');
|
||||||
|
console.log('[ResearchTest] Setting showPersonaModal to true');
|
||||||
|
setShowPersonaModal(true);
|
||||||
|
setPersonaExists(false);
|
||||||
|
|
||||||
|
// Log onboarding and scheduling status for context
|
||||||
if (config.onboarding_completed) {
|
if (config.onboarding_completed) {
|
||||||
console.log('[ResearchTest] ✅ CASE 2: Onboarding completed but persona missing - SHOWING MODAL');
|
|
||||||
console.log('[ResearchTest] Setting showPersonaModal to true');
|
|
||||||
setShowPersonaModal(true);
|
|
||||||
|
|
||||||
// Log if persona was scheduled
|
|
||||||
if (config.persona_scheduled) {
|
if (config.persona_scheduled) {
|
||||||
console.log('[ResearchTest] ℹ️ Research persona generation scheduled for 20 minutes from now');
|
console.log('[ResearchTest] ℹ️ Research persona generation scheduled for 20 minutes from now');
|
||||||
} else {
|
} else {
|
||||||
console.log('[ResearchTest] ⚠️ Persona was not scheduled (may have failed or already scheduled)');
|
console.log('[ResearchTest] ℹ️ Onboarding completed - user can generate persona now or wait for scheduled generation');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[ResearchTest] ✅ CASE 3: Onboarding not completed yet - SKIPPING modal');
|
console.log('[ResearchTest] ℹ️ Onboarding not completed yet - user can still generate research persona');
|
||||||
console.log('[ResearchTest] User has not completed onboarding, will use rule-based suggestions');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +332,7 @@ export const ResearchTest: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setResearchPersona(persona);
|
setResearchPersona(persona);
|
||||||
|
setPersonaExists(true);
|
||||||
|
|
||||||
// Reload config to get updated presets
|
// Reload config to get updated presets
|
||||||
const config = await getResearchConfig();
|
const config = await getResearchConfig();
|
||||||
@@ -324,7 +340,11 @@ export const ResearchTest: React.FC = () => {
|
|||||||
console.log('[ResearchTest] Updating presets with AI-generated presets');
|
console.log('[ResearchTest] Updating presets with AI-generated presets');
|
||||||
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
keywords: preset.keywords.join(', '),
|
keywords: typeof preset.keywords === 'string'
|
||||||
|
? preset.keywords
|
||||||
|
: Array.isArray(preset.keywords)
|
||||||
|
? preset.keywords.join(', ')
|
||||||
|
: 'N/A',
|
||||||
industry: config.persona_defaults.industry || 'General',
|
industry: config.persona_defaults.industry || 'General',
|
||||||
targetAudience: config.persona_defaults.target_audience || 'General',
|
targetAudience: config.persona_defaults.target_audience || 'General',
|
||||||
researchMode: preset.config?.mode || 'comprehensive',
|
researchMode: preset.config?.mode || 'comprehensive',
|
||||||
@@ -372,6 +392,58 @@ export const ResearchTest: React.FC = () => {
|
|||||||
setResults(null);
|
setResults(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenCompetitorModal = async () => {
|
||||||
|
console.log('[handleOpenCompetitorModal] ===== START: Opening competitor analysis modal =====');
|
||||||
|
setShowCompetitorModal(true);
|
||||||
|
setLoadingCompetitors(true);
|
||||||
|
setCompetitorError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[handleOpenCompetitorModal] Calling getCompetitorAnalysis()...');
|
||||||
|
const data = await getCompetitorAnalysis();
|
||||||
|
console.log('[handleOpenCompetitorModal] Received data:', {
|
||||||
|
success: data.success,
|
||||||
|
competitorsCount: data.competitors?.length || 0,
|
||||||
|
error: data.error,
|
||||||
|
hasCompetitors: !!data.competitors && data.competitors.length > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
setCompetitorData(data);
|
||||||
|
if (!data.success) {
|
||||||
|
const errorMsg = data.error || 'Failed to load competitor data';
|
||||||
|
console.error('[handleOpenCompetitorModal] ❌ Failed to load competitor data:', errorMsg);
|
||||||
|
setCompetitorError(errorMsg);
|
||||||
|
} else {
|
||||||
|
console.log('[handleOpenCompetitorModal] ✅ Successfully loaded competitor data');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Failed to load competitor data';
|
||||||
|
console.error('[handleOpenCompetitorModal] ❌ EXCEPTION:', error);
|
||||||
|
setCompetitorError(errorMsg);
|
||||||
|
setCompetitorData(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingCompetitors(false);
|
||||||
|
console.log('[handleOpenCompetitorModal] ===== END: Opening competitor analysis modal =====');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenPersonaDetails = async () => {
|
||||||
|
setShowPersonaDetailsModal(true);
|
||||||
|
setLoadingPersonaDetails(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch fresh persona data
|
||||||
|
const config = await getResearchConfig();
|
||||||
|
if (config.research_persona) {
|
||||||
|
setResearchPersona(config.research_persona);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ResearchTest] Error loading persona details:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingPersonaDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
@@ -403,33 +475,45 @@ export const ResearchTest: React.FC = () => {
|
|||||||
animation: 'float 15s ease-in-out infinite reverse',
|
animation: 'float 15s ease-in-out infinite reverse',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translate(0, 0); }
|
0%, 100% { transform: translate(0, 0); }
|
||||||
50% { transform: translate(20px, 20px); }
|
50% { transform: translate(20px, 20px); }
|
||||||
}
|
}
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -1000px 0; }
|
0% { background-position: -1000px 0; }
|
||||||
100% { background-position: 1000px 0; }
|
100% { background-position: 1000px 0; }
|
||||||
}
|
}
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card-hover {
|
@keyframes glow-green {
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
0%, 100% { box-shadow: 0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3); }
|
||||||
}
|
50% { box-shadow: 0 0 30px rgba(34, 197, 94, 0.8), 0 2px 12px rgba(34, 197, 94, 0.5); }
|
||||||
.card-hover:hover {
|
}
|
||||||
transform: translateY(-4px);
|
@keyframes glow-red {
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
0%, 100% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3); }
|
||||||
}
|
50% { box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), 0 2px 12px rgba(239, 68, 68, 0.5); }
|
||||||
`}</style>
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(255, 255, 255, 0.7)',
|
background: 'rgba(255, 255, 255, 0.7)',
|
||||||
@@ -456,25 +540,105 @@ export const ResearchTest: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
🔬
|
🔬
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ flex: 1 }}>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#0c4a6e',
|
color: '#0c4a6e',
|
||||||
letterSpacing: '-0.01em',
|
letterSpacing: '-0.01em',
|
||||||
}}>
|
}}>
|
||||||
AI-Powered Research Lab
|
AI-Powered Research Lab
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: '2px 0 0 0',
|
margin: '2px 0 0 0',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: '#0369a1',
|
color: '#0369a1',
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
}}>
|
}}>
|
||||||
Enterprise-grade research intelligence at your fingertips
|
Enterprise-grade research intelligence at your fingertips
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCompetitorModal}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#0284c7',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.2)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#0369a1';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(2, 132, 199, 0.3)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#0284c7';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(2, 132, 199, 0.2)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>📊</span>
|
||||||
|
<span>View Competitor Analysis</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenPersonaDetails}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: personaExists ? '#22c55e' : '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
boxShadow: personaExists
|
||||||
|
? '0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3)'
|
||||||
|
: '0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
animation: personaExists ? 'glow-green 2s ease-in-out infinite' : 'glow-red 2s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
if (personaExists) {
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 30px rgba(34, 197, 94, 0.7), 0 4px 12px rgba(34, 197, 94, 0.4)';
|
||||||
|
} else {
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 30px rgba(239, 68, 68, 0.7), 0 4px 12px rgba(239, 68, 68, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
if (personaExists) {
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3)';
|
||||||
|
} else {
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
boxShadow: personaExists
|
||||||
|
? '0 0 8px rgba(255, 255, 255, 0.8)'
|
||||||
|
: '0 0 8px rgba(255, 255, 255, 0.8)',
|
||||||
|
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
}} />
|
||||||
|
<span>{personaExists ? '✓ Research Persona' : '✗ No Persona'}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Badge - Moved to Header */}
|
{/* Status Badge - Moved to Header */}
|
||||||
@@ -859,9 +1023,284 @@ export const ResearchTest: React.FC = () => {
|
|||||||
onGenerate={handleGeneratePersona}
|
onGenerate={handleGeneratePersona}
|
||||||
onCancel={handleCancelPersona}
|
onCancel={handleCancelPersona}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
{/* Competitor Analysis Modal */}
|
||||||
};
|
<OnboardingCompetitorModal
|
||||||
|
open={showCompetitorModal}
|
||||||
|
onClose={() => setShowCompetitorModal(false)}
|
||||||
|
data={competitorData}
|
||||||
|
loading={loadingCompetitors}
|
||||||
|
error={competitorError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Research Persona Details Modal */}
|
||||||
|
{showPersonaDetailsModal && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowPersonaDetailsModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '32px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700', color: '#0f172a' }}>
|
||||||
|
Research Persona Details
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPersonaDetailsModal(false)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#64748b',
|
||||||
|
padding: '4px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingPersonaDetails ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '18px', color: '#64748b' }}>Loading persona details...</div>
|
||||||
|
</div>
|
||||||
|
) : researchPersona ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
border: '1px solid rgba(34, 197, 94, 0.25)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: '600',
|
||||||
|
width: 'fit-content',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#22c55e',
|
||||||
|
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
|
||||||
|
}} />
|
||||||
|
Persona Active
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
Default Settings
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Industry</div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
{researchPersona.default_industry || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Target Audience</div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
{researchPersona.default_target_audience || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Research Mode</div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
{researchPersona.default_research_mode || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Provider</div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
{researchPersona.default_provider || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested Keywords */}
|
||||||
|
{researchPersona.suggested_keywords && researchPersona.suggested_keywords.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
Suggested Keywords ({researchPersona.suggested_keywords.length})
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{researchPersona.suggested_keywords.map((keyword, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#0369a1',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Research Angles */}
|
||||||
|
{researchPersona.research_angles && researchPersona.research_angles.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
Research Angles ({researchPersona.research_angles.length})
|
||||||
|
</h3>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{researchPersona.research_angles.map((angle, idx) => (
|
||||||
|
<li key={idx} style={{ fontSize: '14px', color: '#475569', lineHeight: '1.6' }}>
|
||||||
|
{angle}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommended Presets */}
|
||||||
|
{researchPersona.recommended_presets && researchPersona.recommended_presets.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
Recommended Presets ({researchPersona.recommended_presets.length})
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{researchPersona.recommended_presets.map((preset, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(14, 165, 233, 0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a', marginBottom: '4px' }}>
|
||||||
|
{preset.name || `Preset ${idx + 1}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#64748b' }}>
|
||||||
|
{typeof preset.keywords === 'string'
|
||||||
|
? preset.keywords
|
||||||
|
: Array.isArray(preset.keywords)
|
||||||
|
? (preset.keywords as string[]).join(', ')
|
||||||
|
: 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||||
|
Metadata
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '14px' }}>
|
||||||
|
{researchPersona.generated_at && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#64748b' }}>Generated At:</span>
|
||||||
|
<span style={{ color: '#0f172a', fontWeight: '500' }}>
|
||||||
|
{new Date(researchPersona.generated_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{researchPersona.confidence_score !== undefined && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#64748b' }}>Confidence Score:</span>
|
||||||
|
<span style={{ color: '#0f172a', fontWeight: '500' }}>
|
||||||
|
{(researchPersona.confidence_score * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{researchPersona.version && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#64748b' }}>Version:</span>
|
||||||
|
<span style={{ color: '#0f172a', fontWeight: '500' }}>{researchPersona.version}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', color: '#dc2626', marginBottom: '8px' }}>
|
||||||
|
No Research Persona Found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#64748b' }}>
|
||||||
|
Generate a research persona to get personalized research suggestions and presets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ResearchTest;
|
export default ResearchTest;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import ExecutionLogsTable from '../components/SchedulerDashboard/ExecutionLogsTa
|
|||||||
import FailuresInsights from '../components/SchedulerDashboard/FailuresInsights';
|
import FailuresInsights from '../components/SchedulerDashboard/FailuresInsights';
|
||||||
import SchedulerEventHistory from '../components/SchedulerDashboard/SchedulerEventHistory';
|
import SchedulerEventHistory from '../components/SchedulerDashboard/SchedulerEventHistory';
|
||||||
import SchedulerCharts from '../components/SchedulerDashboard/SchedulerCharts';
|
import SchedulerCharts from '../components/SchedulerDashboard/SchedulerCharts';
|
||||||
import OAuthTokenStatus from '../components/SchedulerDashboard/OAuthTokenStatus';
|
import TaskMonitoringTabs from '../components/SchedulerDashboard/TaskMonitoringTabs';
|
||||||
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
|
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
|
||||||
|
|
||||||
// Terminal-themed styled components
|
// Terminal-themed styled components
|
||||||
@@ -658,9 +658,9 @@ const SchedulerDashboard: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* OAuth Token Status */}
|
{/* Task Monitoring Tabs */}
|
||||||
<Box mb={4}>
|
<Box mb={4}>
|
||||||
<OAuthTokenStatus compact={true} />
|
<TaskMonitoringTabs />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Execution Logs */}
|
{/* Execution Logs */}
|
||||||
@@ -670,7 +670,7 @@ const SchedulerDashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Scheduler Event History */}
|
{/* Scheduler Event History */}
|
||||||
<Box mb={4}>
|
<Box mb={4}>
|
||||||
<SchedulerEventHistory limit={50} />
|
<SchedulerEventHistory />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Scheduler Charts Visualization */}
|
{/* Scheduler Charts Visualization */}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface ResearchSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ResearchMode = 'basic' | 'comprehensive' | 'targeted';
|
export type ResearchMode = 'basic' | 'comprehensive' | 'targeted';
|
||||||
export type ResearchProvider = 'google' | 'exa';
|
export type ResearchProvider = 'google' | 'exa' | 'tavily';
|
||||||
export type SourceType = 'web' | 'academic' | 'news' | 'industry' | 'expert';
|
export type SourceType = 'web' | 'academic' | 'news' | 'industry' | 'expert';
|
||||||
export type DateRange = 'last_week' | 'last_month' | 'last_3_months' | 'last_6_months' | 'last_year' | 'all_time';
|
export type DateRange = 'last_week' | 'last_month' | 'last_3_months' | 'last_6_months' | 'last_year' | 'all_time';
|
||||||
|
|
||||||
@@ -37,6 +37,22 @@ export interface ResearchConfig {
|
|||||||
exa_include_domains?: string[];
|
exa_include_domains?: string[];
|
||||||
exa_exclude_domains?: string[];
|
exa_exclude_domains?: string[];
|
||||||
exa_search_type?: 'auto' | 'keyword' | 'neural';
|
exa_search_type?: 'auto' | 'keyword' | 'neural';
|
||||||
|
// Tavily-specific options
|
||||||
|
tavily_topic?: 'general' | 'news' | 'finance';
|
||||||
|
tavily_search_depth?: 'basic' | 'advanced';
|
||||||
|
tavily_include_domains?: string[];
|
||||||
|
tavily_exclude_domains?: string[];
|
||||||
|
tavily_include_answer?: boolean | 'basic' | 'advanced';
|
||||||
|
tavily_include_raw_content?: boolean | 'markdown' | 'text';
|
||||||
|
tavily_include_images?: boolean;
|
||||||
|
tavily_include_image_descriptions?: boolean;
|
||||||
|
tavily_include_favicon?: boolean;
|
||||||
|
tavily_time_range?: 'day' | 'week' | 'month' | 'year' | 'd' | 'w' | 'm' | 'y';
|
||||||
|
tavily_start_date?: string; // YYYY-MM-DD
|
||||||
|
tavily_end_date?: string; // YYYY-MM-DD
|
||||||
|
tavily_country?: string;
|
||||||
|
tavily_chunks_per_source?: number; // 1-3
|
||||||
|
tavily_auto_parameters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogResearchRequest {
|
export interface BlogResearchRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user