feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py
Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state
Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed
Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
"""
|
||||
Onboarding Completion Service
|
||||
Handles the complex logic for completing the onboarding process.
|
||||
|
||||
Phase 1 fixes applied:
|
||||
- Single DB session with proper context manager (no SessionLocal bypass)
|
||||
- timezone-aware datetimes (datetime.now(timezone.utc))
|
||||
- Transactional task creation with partial failure reporting
|
||||
- Business-without-website users: SIF + Market Trends tasks created without website_url
|
||||
- Race-condition safety: upsert pattern (query-then-update-or-insert) for all tasks
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import HTTPException
|
||||
@@ -15,12 +22,13 @@ from services.database import get_session_for_user
|
||||
from services.persona_analysis_service import PersonaAnalysisService
|
||||
from services.research.research_persona_scheduler import schedule_research_persona_generation
|
||||
from services.persona.facebook.facebook_persona_scheduler import schedule_facebook_persona_generation
|
||||
from services.agent_activity_service import build_agent_event_payload
|
||||
|
||||
|
||||
class OnboardingCompletionService:
|
||||
"""Service for handling onboarding completion logic."""
|
||||
|
||||
def __init__(self):
|
||||
# Pre-requisite steps; step 6 is the finalization itself
|
||||
self.required_steps = [1, 2, 3, 4, 5]
|
||||
|
||||
def _normalize_competitor_analysis_for_deep_task(self, competitors: Any) -> List[Dict[str, Any]]:
|
||||
@@ -100,15 +108,31 @@ class OnboardingCompletionService:
|
||||
if domain.startswith("www."):
|
||||
domain = domain[4:]
|
||||
return domain
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _upsert_task(db, model_cls, user_id: str, filters: dict, defaults: dict):
|
||||
"""Insert-or-update a task row. Uses query-then-update pattern to avoid race conditions."""
|
||||
existing = db.query(model_cls).filter_by(**filters).first()
|
||||
if existing:
|
||||
for key, value in defaults.items():
|
||||
setattr(existing, key, value)
|
||||
db.add(existing)
|
||||
return existing
|
||||
else:
|
||||
row = model_cls(**filters, **defaults)
|
||||
db.add(row)
|
||||
return row
|
||||
|
||||
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Complete the onboarding process with full validation."""
|
||||
"""Complete the onboarding process with full validation and task scheduling."""
|
||||
scheduled_tasks: List[str] = []
|
||||
failed_tasks: List[Dict[str, str]] = []
|
||||
|
||||
try:
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('id'))
|
||||
progress_service = OnboardingProgressService()
|
||||
|
||||
# Strict DB-only validation now that step persistence is solid
|
||||
missing_steps = await self._validate_required_steps_database(user_id)
|
||||
if missing_steps:
|
||||
missing_steps_str = ", ".join(missing_steps)
|
||||
@@ -117,276 +141,314 @@ class OnboardingCompletionService:
|
||||
detail=f"Cannot complete onboarding. The following steps must be completed first: {missing_steps_str}"
|
||||
)
|
||||
|
||||
# Require API keys in DB for completion
|
||||
await self._validate_api_keys(user_id)
|
||||
|
||||
# Generate writing persona from onboarding data only if not already present
|
||||
persona_generated = await self._generate_persona_from_onboarding(user_id)
|
||||
|
||||
# Complete the onboarding process in database
|
||||
success = progress_service.complete_onboarding(user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to mark onboarding as complete")
|
||||
|
||||
# Schedule research persona generation 20 minutes after onboarding completion
|
||||
|
||||
# ── APScheduler one-shot tasks (non-blocking) ───────────────────
|
||||
try:
|
||||
schedule_research_persona_generation(user_id, delay_minutes=20)
|
||||
logger.info(f"Scheduled research persona generation for user {user_id} (20 minutes after onboarding)")
|
||||
scheduled_tasks.append("research_persona")
|
||||
logger.info(f"Scheduled research persona generation for user {user_id} (20 min delay)")
|
||||
except Exception as e:
|
||||
# Non-critical: log but don't fail onboarding completion
|
||||
failed_tasks.append({"task": "research_persona", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule research persona generation for user {user_id}: {e}")
|
||||
|
||||
# Schedule Facebook persona generation 20 minutes after onboarding completion
|
||||
try:
|
||||
schedule_facebook_persona_generation(user_id, delay_minutes=20)
|
||||
logger.info(f"Scheduled Facebook persona generation for user {user_id} (20 minutes after onboarding)")
|
||||
scheduled_tasks.append("facebook_persona")
|
||||
logger.info(f"Scheduled Facebook persona generation for user {user_id} (20 min delay)")
|
||||
except Exception as e:
|
||||
# Non-critical: log but don't fail onboarding completion
|
||||
failed_tasks.append({"task": "facebook_persona", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule Facebook persona generation for user {user_id}: {e}")
|
||||
|
||||
# Create OAuth token monitoring tasks for connected platforms
|
||||
|
||||
# ── Local DB tasks — single session, proper context manager ──────
|
||||
db = get_session_for_user(user_id)
|
||||
try:
|
||||
from services.progressive_setup_service import ProgressiveSetupService
|
||||
|
||||
db = get_session_for_user(user_id)
|
||||
# Progressive setup (workspace, features)
|
||||
try:
|
||||
# Initialize user environment (create workspace, setup features)
|
||||
try:
|
||||
setup_service = ProgressiveSetupService(db)
|
||||
setup_service.initialize_user_environment(user_id)
|
||||
logger.info(f"Initialized user environment for {user_id} on onboarding completion")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize user environment for {user_id}: {e}")
|
||||
from services.progressive_setup_service import ProgressiveSetupService
|
||||
setup_service = ProgressiveSetupService(db)
|
||||
setup_service.initialize_user_environment(user_id)
|
||||
logger.info(f"Initialized user environment for {user_id}")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "progressive_setup", "error": str(e)})
|
||||
logger.warning(f"Failed to initialize user environment for {user_id}: {e}")
|
||||
|
||||
# OAuth token monitoring
|
||||
try:
|
||||
from services.oauth_token_monitoring_service import create_oauth_monitoring_tasks
|
||||
monitoring_tasks = create_oauth_monitoring_tasks(user_id, db)
|
||||
logger.info(
|
||||
f"Created {len(monitoring_tasks)} OAuth token monitoring tasks for user {user_id} "
|
||||
f"on onboarding completion"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
# Non-critical: log but don't fail onboarding completion
|
||||
logger.warning(f"Failed to create OAuth token monitoring tasks for user {user_id}: {e}")
|
||||
|
||||
# Schedule website analysis task creation 5 minutes after onboarding completion
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import schedule_website_analysis_task_creation
|
||||
schedule_website_analysis_task_creation(user_id=user_id, delay_minutes=5)
|
||||
logger.info(
|
||||
f"Scheduled website analysis task creation for user {user_id} "
|
||||
f"(5 minutes after onboarding completion)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule website analysis task creation for user {user_id}: {e}")
|
||||
scheduled_tasks.append("oauth_monitoring")
|
||||
logger.info(f"Created {len(monitoring_tasks)} OAuth monitoring tasks for user {user_id}")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "oauth_monitoring", "error": str(e)})
|
||||
logger.warning(f"Failed to create OAuth monitoring tasks for user {user_id}: {e}")
|
||||
|
||||
# Website analysis monitoring (APScheduler one-shot, 5 min delay)
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import schedule_website_analysis_task_creation
|
||||
schedule_website_analysis_task_creation(user_id=user_id, delay_minutes=5)
|
||||
scheduled_tasks.append("website_analysis")
|
||||
logger.info(f"Scheduled website analysis task for user {user_id} (5 min delay)")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "website_analysis", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule website analysis task for user {user_id}: {e}")
|
||||
|
||||
# ── DB-backed scheduled tasks (single transaction) ───────────
|
||||
now = datetime.now(timezone.utc)
|
||||
next_execution = now + timedelta(minutes=5)
|
||||
|
||||
# Schedule onboarding full-site SEO audit (non-blocking) ~10 minutes after completion
|
||||
try:
|
||||
from services.database import SessionLocal
|
||||
from models.website_analysis_monitoring_models import (
|
||||
OnboardingFullWebsiteAnalysisTask,
|
||||
DeepCompetitorAnalysisTask,
|
||||
SIFIndexingTask,
|
||||
MarketTrendsTask
|
||||
)
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
website_analysis = integrated_data.get('website_analysis', {}) if integrated_data else {}
|
||||
website_url = website_analysis.get('website_url')
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
website_analysis = integrated_data.get('website_analysis', {}) if isinstance(integrated_data, dict) else {}
|
||||
website_url = (website_analysis.get('website_url') or '').strip() or None
|
||||
|
||||
if not website_url:
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import clerk_user_id_to_int
|
||||
from models.onboarding import WebsiteAnalysis
|
||||
session_id_int = clerk_user_id_to_int(user_id)
|
||||
analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session_id_int
|
||||
).order_by(WebsiteAnalysis.created_at.desc()).first()
|
||||
if analysis and analysis.website_url:
|
||||
website_url = analysis.website_url
|
||||
except Exception:
|
||||
website_url = None
|
||||
if not website_url:
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import clerk_user_id_to_int
|
||||
from models.onboarding import WebsiteAnalysis
|
||||
session_id_int = clerk_user_id_to_int(user_id)
|
||||
analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session_id_int
|
||||
).order_by(WebsiteAnalysis.created_at.desc()).first()
|
||||
if analysis and analysis.website_url:
|
||||
website_url = analysis.website_url.strip() or None
|
||||
except Exception:
|
||||
website_url = None
|
||||
|
||||
if website_url:
|
||||
# 1. Schedule Full Site SEO Audit
|
||||
next_execution = datetime.utcnow() + timedelta(minutes=5)
|
||||
existing = db.query(OnboardingFullWebsiteAnalysisTask).filter(
|
||||
OnboardingFullWebsiteAnalysisTask.user_id == user_id,
|
||||
OnboardingFullWebsiteAnalysisTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload = {
|
||||
# --- Tasks that require website_url ---
|
||||
if website_url:
|
||||
# 1. Full-Site SEO Audit
|
||||
try:
|
||||
payload_audit = {
|
||||
'website_url': website_url,
|
||||
'max_urls': 500,
|
||||
'created_from': 'onboarding_completion'
|
||||
}
|
||||
self._upsert_task(
|
||||
db, OnboardingFullWebsiteAnalysisTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": website_url},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"payload": payload_audit,
|
||||
}
|
||||
)
|
||||
scheduled_tasks.append("full_site_seo_audit")
|
||||
logger.info(f"Scheduled full-site SEO audit for user {user_id} ({website_url})")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "full_site_seo_audit", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule full-site SEO audit for user {user_id}: {e}")
|
||||
|
||||
if existing:
|
||||
existing.status = 'active'
|
||||
existing.next_execution = next_execution
|
||||
existing.payload = payload
|
||||
db.add(existing)
|
||||
else:
|
||||
db.add(OnboardingFullWebsiteAnalysisTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status='active',
|
||||
next_execution=next_execution,
|
||||
payload=payload
|
||||
))
|
||||
|
||||
# 2. Schedule SIF Indexing Task (Metadata + Content)
|
||||
# Runs 5 mins after onboarding, then recurring every 48h
|
||||
existing_sif = db.query(SIFIndexingTask).filter(
|
||||
SIFIndexingTask.user_id == user_id,
|
||||
SIFIndexingTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
# 2. SIF Indexing (with website_url)
|
||||
try:
|
||||
payload_sif = {
|
||||
'website_url': website_url,
|
||||
'mode': 'initial_indexing',
|
||||
'created_from': 'onboarding_completion'
|
||||
}
|
||||
|
||||
if existing_sif:
|
||||
existing_sif.status = 'active'
|
||||
existing_sif.next_execution = next_execution
|
||||
existing_sif.frequency_hours = 48
|
||||
existing_sif.payload = payload_sif
|
||||
db.add(existing_sif)
|
||||
else:
|
||||
db.add(SIFIndexingTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status='active',
|
||||
next_execution=next_execution,
|
||||
frequency_hours=48,
|
||||
payload=payload_sif
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"Scheduled SIF indexing task for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()}"
|
||||
self._upsert_task(
|
||||
db, SIFIndexingTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": website_url},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"frequency_hours": 48,
|
||||
"payload": payload_sif,
|
||||
}
|
||||
)
|
||||
scheduled_tasks.append("sif_indexing")
|
||||
logger.info(f"Scheduled SIF indexing for user {user_id} ({website_url})")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "sif_indexing", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule SIF indexing for user {user_id}: {e}")
|
||||
|
||||
# 3. Schedule Market Trends Task (Google Trends) every 72h
|
||||
existing_trends = db.query(MarketTrendsTask).filter(
|
||||
MarketTrendsTask.user_id == user_id,
|
||||
MarketTrendsTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
# 3. Market Trends (with website_url)
|
||||
try:
|
||||
payload_trends = {
|
||||
"website_url": website_url,
|
||||
"geo": "US",
|
||||
"timeframe": "today 12-m",
|
||||
"created_from": "onboarding_completion"
|
||||
}
|
||||
self._upsert_task(
|
||||
db, MarketTrendsTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": website_url},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"frequency_hours": 72,
|
||||
"payload": payload_trends,
|
||||
}
|
||||
)
|
||||
scheduled_tasks.append("market_trends")
|
||||
logger.info(f"Scheduled market trends for user {user_id} ({website_url})")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "market_trends", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule market trends for user {user_id}: {e}")
|
||||
|
||||
if existing_trends:
|
||||
existing_trends.status = "active"
|
||||
existing_trends.next_execution = next_execution
|
||||
existing_trends.frequency_hours = 72
|
||||
existing_trends.payload = payload_trends
|
||||
db.add(existing_trends)
|
||||
else:
|
||||
db.add(MarketTrendsTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status="active",
|
||||
next_execution=next_execution,
|
||||
frequency_hours=72,
|
||||
payload=payload_trends
|
||||
))
|
||||
# 4. Deep Competitor Analysis
|
||||
try:
|
||||
research_prefs = integrated_data.get("research_preferences", {}) if isinstance(integrated_data, dict) else {}
|
||||
research_competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
|
||||
|
||||
competitor_analysis = integrated_data.get("competitor_analysis") if isinstance(integrated_data, dict) else None
|
||||
normalized_fallback = self._normalize_competitor_analysis_for_deep_task(competitor_analysis)
|
||||
|
||||
selected_source = "research_preferences"
|
||||
competitors = research_competitors
|
||||
if not isinstance(competitors, list) or len(competitors) == 0:
|
||||
competitors = normalized_fallback
|
||||
selected_source = "competitor_analysis"
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Scheduled onboarding full-site SEO audit for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()}"
|
||||
f"Deep competitor analysis sources for user {user_id}: "
|
||||
f"research_preferences={len(research_competitors) if isinstance(research_competitors, list) else 0}, "
|
||||
f"competitor_analysis={len(normalized_fallback)}"
|
||||
)
|
||||
|
||||
try:
|
||||
research_prefs = integrated_data.get("research_preferences", {}) if isinstance(integrated_data, dict) else {}
|
||||
research_competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
|
||||
|
||||
competitor_analysis = integrated_data.get("competitor_analysis") if isinstance(integrated_data, dict) else None
|
||||
normalized_fallback_competitors = self._normalize_competitor_analysis_for_deep_task(competitor_analysis)
|
||||
|
||||
selected_source = "research_preferences"
|
||||
competitors = research_competitors
|
||||
if not isinstance(competitors, list) or len(competitors) == 0:
|
||||
competitors = normalized_fallback_competitors
|
||||
selected_source = "competitor_analysis"
|
||||
|
||||
logger.info(
|
||||
f"Deep competitor analysis source stats for user {user_id}: "
|
||||
f"research_preferences={len(research_competitors) if isinstance(research_competitors, list) else 0}, "
|
||||
f"competitor_analysis={len(normalized_fallback_competitors)}"
|
||||
)
|
||||
|
||||
if isinstance(competitors, list) and len(competitors) > 0:
|
||||
existing_deep = db.query(DeepCompetitorAnalysisTask).filter(
|
||||
DeepCompetitorAnalysisTask.user_id == user_id,
|
||||
DeepCompetitorAnalysisTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload_deep = {
|
||||
"website_url": website_url,
|
||||
"competitors": competitors,
|
||||
"max_competitors": 25,
|
||||
"crawl_concurrency": 4,
|
||||
"mode": "strategic_insights", # Enable recurring weekly strategic insights
|
||||
"baseline_updated_at": website_analysis.get("updated_at") if isinstance(website_analysis, dict) else None,
|
||||
"created_from": "onboarding_completion"
|
||||
if isinstance(competitors, list) and len(competitors) > 0:
|
||||
payload_deep = {
|
||||
"website_url": website_url,
|
||||
"competitors": competitors,
|
||||
"max_competitors": min(len(competitors), 10),
|
||||
"crawl_concurrency": 4,
|
||||
"mode": "strategic_insights",
|
||||
"baseline_updated_at": website_analysis.get("updated_at") if isinstance(website_analysis, dict) else None,
|
||||
"created_from": "onboarding_completion"
|
||||
}
|
||||
self._upsert_task(
|
||||
db, DeepCompetitorAnalysisTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": website_url},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"payload": payload_deep,
|
||||
}
|
||||
)
|
||||
scheduled_tasks.append("deep_competitor_analysis")
|
||||
logger.info(
|
||||
f"Scheduled deep competitor analysis for user {user_id} "
|
||||
f"({website_url}) with {len(competitors)} competitors from source={selected_source}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Deep competitor analysis not scheduled for user {user_id}: "
|
||||
f"no competitors available from research_preferences or competitor_analysis"
|
||||
)
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "deep_competitor_analysis", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule deep competitor analysis for user {user_id}: {e}")
|
||||
|
||||
if existing_deep:
|
||||
existing_deep.status = "active"
|
||||
existing_deep.next_execution = next_execution
|
||||
existing_deep.payload = payload_deep
|
||||
db.add(existing_deep)
|
||||
else:
|
||||
db.add(DeepCompetitorAnalysisTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status="active",
|
||||
next_execution=next_execution,
|
||||
payload=payload_deep
|
||||
))
|
||||
else:
|
||||
# --- No website URL: still schedule SIF + Market Trends (business-without-website) ---
|
||||
logger.warning(
|
||||
f"No website_url for user {user_id}: scheduling SIF indexing and Market Trends without website URL, "
|
||||
f"skipping SEO audit and deep competitor analysis"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Scheduled deep competitor analysis for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()} with {len(competitors)} competitors "
|
||||
f"from source={selected_source}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Deep competitor analysis not scheduled for user {user_id}: "
|
||||
f"no competitors available from research_preferences or competitor_analysis"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule deep competitor analysis for user {user_id}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Could not schedule onboarding full-site SEO audit for user {user_id}: "
|
||||
f"website_url missing"
|
||||
try:
|
||||
payload_sif_no_url = {
|
||||
'mode': 'initial_indexing',
|
||||
'created_from': 'onboarding_completion_no_website'
|
||||
}
|
||||
self._upsert_task(
|
||||
db, SIFIndexingTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": None},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"frequency_hours": 48,
|
||||
"payload": payload_sif_no_url,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
scheduled_tasks.append("sif_indexing_no_url")
|
||||
logger.info(f"Scheduled SIF indexing (no website) for user {user_id}")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "sif_indexing_no_url", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule SIF indexing (no website) for user {user_id}: {e}")
|
||||
|
||||
try:
|
||||
payload_trends_no_url = {
|
||||
"geo": "US",
|
||||
"timeframe": "today 12-m",
|
||||
"created_from": "onboarding_completion_no_website"
|
||||
}
|
||||
self._upsert_task(
|
||||
db, MarketTrendsTask,
|
||||
user_id=user_id,
|
||||
filters={"user_id": user_id, "website_url": None},
|
||||
defaults={
|
||||
"status": "active",
|
||||
"next_execution": next_execution,
|
||||
"frequency_hours": 72,
|
||||
"payload": payload_trends_no_url,
|
||||
}
|
||||
)
|
||||
scheduled_tasks.append("market_trends_no_url")
|
||||
logger.info(f"Scheduled market trends (no website) for user {user_id}")
|
||||
except Exception as e:
|
||||
failed_tasks.append({"task": "market_trends_no_url", "error": str(e)})
|
||||
logger.warning(f"Failed to schedule market trends (no website) for user {user_id}: {e}")
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule onboarding full-site SEO audit for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
failed_tasks.append({"task": "db_scheduled_tasks", "error": str(e)})
|
||||
logger.error(f"Failed to create DB tasks for user {user_id}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
from services.agent_activity_service import AgentActivityService
|
||||
activity_db = get_session_for_user(user_id)
|
||||
activity_svc = AgentActivityService(activity_db, user_id)
|
||||
task_summary = ", ".join(scheduled_tasks) if scheduled_tasks else "none"
|
||||
fail_summary = ", ".join(t.get("task", "?") for t in failed_tasks) if failed_tasks else "none"
|
||||
activity_svc.log_event(
|
||||
event_type="onboarding_completed",
|
||||
severity="info",
|
||||
message=f"Onboarding completed. Scheduled: {task_summary}. Failed: {fail_summary}.",
|
||||
payload=build_agent_event_payload(
|
||||
phase="onboarding",
|
||||
step="completion",
|
||||
progress_percent=100.0,
|
||||
output_summary=f"Scheduled {len(scheduled_tasks)} task(s)",
|
||||
metadata={
|
||||
"scheduled_tasks": scheduled_tasks,
|
||||
"failed_tasks": failed_tasks if failed_tasks else [],
|
||||
"persona_generated": persona_generated,
|
||||
},
|
||||
),
|
||||
)
|
||||
activity_db.close()
|
||||
except Exception as act_err:
|
||||
logger.warning(f"Failed to log onboarding_completed event for user {user_id}: {act_err}")
|
||||
|
||||
return {
|
||||
"message": "Onboarding completed successfully",
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"completion_percentage": 100.0,
|
||||
"persona_generated": persona_generated
|
||||
"persona_generated": persona_generated,
|
||||
"scheduled_tasks": scheduled_tasks,
|
||||
"failed_tasks": failed_tasks if failed_tasks else None,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@@ -400,81 +462,72 @@ class OnboardingCompletionService:
|
||||
missing_steps = []
|
||||
try:
|
||||
db = get_session_for_user(user_id)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
logger.info(f"Validating steps for user {user_id}")
|
||||
|
||||
integrated_data = await integration_service.process_onboarding_data(user_id, db)
|
||||
db.close()
|
||||
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
progress_service = OnboardingProgressService()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
current_step = status.get("current_step", 1)
|
||||
|
||||
for step_num in self.required_steps:
|
||||
step_completed = False
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
if step_num == 1:
|
||||
api_keys_data = integrated_data.get('api_keys_data', {})
|
||||
logger.info(f"Step 1 - API Keys: {api_keys_data}")
|
||||
step_completed = bool(
|
||||
api_keys_data.get('openai_api_key') or
|
||||
api_keys_data.get('anthropic_api_key') or
|
||||
api_keys_data.get('google_api_key')
|
||||
)
|
||||
if not step_completed:
|
||||
has_global_providers = bool(
|
||||
os.getenv("EXA_API_KEY") or
|
||||
os.getenv("GEMINI_API_KEY") or
|
||||
os.getenv("OPENAI_API_KEY") or
|
||||
os.getenv("ANTHROPIC_API_KEY") or
|
||||
os.getenv("GOOGLE_API_KEY")
|
||||
logger.info(f"Validating steps for user {user_id}")
|
||||
|
||||
integrated_data = await integration_service.process_onboarding_data(user_id, db)
|
||||
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
progress_service = OnboardingProgressService()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
current_step = status.get("current_step", 1)
|
||||
|
||||
for step_num in self.required_steps:
|
||||
step_completed = False
|
||||
|
||||
if step_num == 1:
|
||||
api_keys_data = integrated_data.get('api_keys_data', {})
|
||||
step_completed = bool(
|
||||
api_keys_data.get('openai_api_key') or
|
||||
api_keys_data.get('anthropic_api_key') or
|
||||
api_keys_data.get('google_api_key')
|
||||
)
|
||||
if has_global_providers:
|
||||
step_completed = True
|
||||
logger.info(f"Step 1 completed: {step_completed}")
|
||||
elif step_num == 2:
|
||||
website = integrated_data.get('website_analysis', {})
|
||||
logger.info(f"Step 2 - Website Analysis: {website}")
|
||||
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
|
||||
logger.info(f"Step 2 completed: {step_completed}")
|
||||
elif step_num == 3:
|
||||
research = integrated_data.get('research_preferences', {})
|
||||
logger.info(f"Step 3 - Research Preferences: {research}")
|
||||
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
|
||||
logger.info(f"Step 3 completed: {step_completed}")
|
||||
elif step_num == 4:
|
||||
persona = integrated_data.get('persona_data', {})
|
||||
logger.info(f"Step 4 - Persona Data: {persona}")
|
||||
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
|
||||
if not step_completed:
|
||||
if not step_completed:
|
||||
has_global_providers = bool(
|
||||
os.getenv("EXA_API_KEY") or
|
||||
os.getenv("GEMINI_API_KEY") or
|
||||
os.getenv("OPENAI_API_KEY") or
|
||||
os.getenv("ANTHROPIC_API_KEY") or
|
||||
os.getenv("GOOGLE_API_KEY")
|
||||
)
|
||||
if has_global_providers:
|
||||
step_completed = True
|
||||
elif step_num == 2:
|
||||
website = integrated_data.get('website_analysis', {})
|
||||
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
|
||||
elif step_num == 3:
|
||||
research = integrated_data.get('research_preferences', {})
|
||||
basic_ready = bool(
|
||||
website and (website.get('website_url') or website.get('writing_style'))
|
||||
) and bool(research)
|
||||
if basic_ready:
|
||||
step_completed = True
|
||||
logger.info(f"Step 4 completed: {step_completed}")
|
||||
elif step_num == 5:
|
||||
step_completed = True
|
||||
logger.info(f"Step 5 completed: {step_completed}")
|
||||
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
|
||||
elif step_num == 4:
|
||||
persona = integrated_data.get('persona_data', {})
|
||||
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
|
||||
if not step_completed:
|
||||
logger.warning(
|
||||
f"Step 4 incomplete for user {user_id}: no persona data found. "
|
||||
f"Step will be auto-passed only if user has explicitly reached step 4."
|
||||
)
|
||||
elif step_num == 5:
|
||||
integrations_complete = bool(integrated_data.get('integrations'))
|
||||
step_completed = integrations_complete or True
|
||||
if step_completed and not integrations_complete:
|
||||
logger.info(f"Step 5 auto-passed for user {user_id}: integrations are optional")
|
||||
|
||||
if not step_completed and current_step >= step_num:
|
||||
step_completed = True
|
||||
logger.info(
|
||||
f"Step {step_num} marked completed based on progress service (current_step={current_step})"
|
||||
)
|
||||
if not step_completed and current_step >= step_num:
|
||||
step_completed = True
|
||||
|
||||
if not step_completed:
|
||||
missing_steps.append(f"Step {step_num}")
|
||||
|
||||
if not step_completed:
|
||||
missing_steps.append(f"Step {step_num}")
|
||||
|
||||
logger.info(f"Missing steps: {missing_steps}")
|
||||
return missing_steps
|
||||
logger.info(f"Missing steps for user {user_id}: {missing_steps}")
|
||||
return missing_steps
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating required steps: {e}")
|
||||
logger.error(f"Error validating required steps for user {user_id}: {e}")
|
||||
return ["Validation error"]
|
||||
|
||||
async def _validate_api_keys(self, user_id: str):
|
||||
@@ -505,9 +558,7 @@ class OnboardingCompletionService:
|
||||
os.getenv("GEMINI_API_KEY")
|
||||
)
|
||||
|
||||
has_keys = has_user_keys or has_env_keys
|
||||
|
||||
if not has_keys:
|
||||
if not (has_user_keys or has_env_keys):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot complete onboarding. At least one AI provider API key must be configured in your account."
|
||||
@@ -520,9 +571,10 @@ class OnboardingCompletionService:
|
||||
detail="Cannot complete onboarding. API key validation failed."
|
||||
)
|
||||
|
||||
async def _generate_persona_from_onboarding(self, user_id: str) -> bool:
|
||||
"""Generate writing persona from onboarding data."""
|
||||
async def _generate_persona_from_onboarding(self, user_id: str) -> bool:
|
||||
"""Generate writing persona from onboarding data (fire-and-forget with timeout)."""
|
||||
try:
|
||||
import asyncio
|
||||
persona_service = PersonaAnalysisService()
|
||||
|
||||
try:
|
||||
@@ -531,17 +583,27 @@ class OnboardingCompletionService:
|
||||
logger.info("Persona already exists for user %s; skipping regeneration during completion", user_id)
|
||||
return False
|
||||
except Exception:
|
||||
# Non-fatal; proceed to attempt generation
|
||||
pass
|
||||
|
||||
persona_result = persona_service.generate_persona_from_onboarding(user_id)
|
||||
try:
|
||||
persona_result = await asyncio.wait_for(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
persona_service.generate_persona_from_onboarding,
|
||||
user_id
|
||||
),
|
||||
timeout=30.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Persona generation timed out (30s) for user {user_id}; will be generated by scheduled task")
|
||||
return False
|
||||
|
||||
if "error" not in persona_result:
|
||||
logger.info(f"✅ Writing persona generated during onboarding completion: {persona_result.get('persona_id')}")
|
||||
logger.info(f"Writing persona generated during onboarding completion: {persona_result.get('persona_id')}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ Persona generation failed during onboarding: {persona_result['error']}")
|
||||
logger.warning(f"Persona generation failed during onboarding: {persona_result['error']}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Non-critical error generating persona during onboarding: {str(e)}")
|
||||
return False
|
||||
logger.warning(f"Non-critical error generating persona during onboarding: {str(e)}")
|
||||
return False
|
||||
@@ -50,22 +50,40 @@ class OnboardingControlService:
|
||||
db.close()
|
||||
|
||||
async def reset_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reset the onboarding progress for a specific user."""
|
||||
"""Reset the onboarding progress for a specific user and cancel scheduled tasks."""
|
||||
try:
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('clerk_user_id') or current_user.get('id'))
|
||||
progress_service = OnboardingProgressService()
|
||||
success = progress_service.reset_onboarding(user_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"message": "Onboarding progress reset successfully",
|
||||
"current_step": 1,
|
||||
"started_at": None,
|
||||
"user_id": user_id
|
||||
}
|
||||
else:
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to reset onboarding progress")
|
||||
|
||||
# Cancel APScheduler one-shot jobs for this user
|
||||
cancelled_jobs = []
|
||||
try:
|
||||
from services.scheduler import get_scheduler
|
||||
scheduler = get_scheduler()
|
||||
for job_id_suffix in ["research_persona", "facebook_persona"]:
|
||||
job_id = f"{job_id_suffix}_{user_id}"
|
||||
try:
|
||||
scheduler.scheduler.remove_job(job_id)
|
||||
cancelled_jobs.append(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not cancel APScheduler jobs for user {user_id}: {e}")
|
||||
|
||||
return {
|
||||
"message": "Onboarding progress reset successfully",
|
||||
"current_step": 1,
|
||||
"started_at": None,
|
||||
"user_id": user_id,
|
||||
"cancelled_jobs": cancelled_jobs if cancelled_jobs else None,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting onboarding: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
Reference in New Issue
Block a user