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:
@@ -66,6 +66,7 @@ class RecommendationItem(BaseModel):
|
||||
|
||||
class SEOApplyRecommendationsRequest(BaseModel):
|
||||
title: str = Field(..., description="Current blog title")
|
||||
introduction: str | None = Field(default=None, description="Current blog introduction text")
|
||||
sections: List[Dict[str, Any]] = Field(..., description="Array of sections with id, heading, content")
|
||||
outline: List[Dict[str, Any]] = Field(default_factory=list, description="Outline structure for context")
|
||||
research: Dict[str, Any] = Field(default_factory=dict, description="Research data used for the blog")
|
||||
@@ -122,7 +123,7 @@ async def section_originality_tools(
|
||||
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
|
||||
|
||||
from services.intelligence.sif_integration import SIFIntegrationService
|
||||
from services.intelligence.sif_agents import ContentGuardianAgent
|
||||
from services.intelligence.agents.specialized import ContentGuardianAgent
|
||||
|
||||
sif_service = SIFIntegrationService(user_id)
|
||||
intelligence = sif_service.intelligence_service
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -19,7 +19,7 @@ from services.seo import SEODashboardService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from models.onboarding import SEOPageAudit, WebsiteAnalysis, OnboardingSession
|
||||
from models.onboarding import SEOPageAudit, WebsiteAnalysis, OnboardingSession, CompetitorAnalysis
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from sqlalchemy import desc
|
||||
@@ -752,6 +752,391 @@ async def get_keyword_gaps(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get keyword gaps: {str(e)}")
|
||||
|
||||
|
||||
async def get_serp_gaps(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
topics: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get SERP gap analysis — detect which competitors rank for given topics.
|
||||
|
||||
Uses Google Custom Search `site:` queries per competitor domain to detect
|
||||
ranking presence. Topics can be provided explicitly or derived from the
|
||||
user's latest SIF semantic gap analysis.
|
||||
|
||||
Args:
|
||||
topics: Optional list of topic phrases. If omitted, uses the user's
|
||||
latest SIF semantic gaps (up to 12 topics).
|
||||
|
||||
Returns:
|
||||
Dict with gaps list and metadata.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get("id"))
|
||||
|
||||
# If no topics provided, fetch from SIF semantic gaps
|
||||
if not topics:
|
||||
try:
|
||||
from services.intelligence.agents.specialized import StrategyArchitectAgent
|
||||
from services.intelligence.txtai_service import TxtaiIntelligenceService
|
||||
|
||||
integration = OnboardingDataIntegrationService()
|
||||
db_session = get_session_for_user(user_id)
|
||||
if db_session:
|
||||
try:
|
||||
integrated = integration.get_integrated_data_sync(
|
||||
user_id, db_session
|
||||
)
|
||||
competitor_indices = []
|
||||
if integrated and integrated.get("competitor_analysis"):
|
||||
competitor_indices = [
|
||||
i
|
||||
for i, _ in enumerate(
|
||||
integrated["competitor_analysis"]
|
||||
)
|
||||
]
|
||||
agent = StrategyArchitectAgent(
|
||||
TxtaiIntelligenceService(user_id), user_id
|
||||
)
|
||||
gaps = await agent.find_semantic_gaps(competitor_indices)
|
||||
topics = [g["topic"] for g in gaps[:12]]
|
||||
finally:
|
||||
db_session.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not derive topics from SIF gaps: {e}. "
|
||||
"Pass topics explicitly."
|
||||
)
|
||||
return {
|
||||
"gaps": [],
|
||||
"message": "No topics provided and unable to derive from SIF gaps.",
|
||||
}
|
||||
|
||||
if not topics:
|
||||
return {
|
||||
"gaps": [],
|
||||
"message": "No topics to analyze. Complete onboarding and SIF indexing first.",
|
||||
}
|
||||
|
||||
# Get competitor domains from onboarding
|
||||
competitor_domains = []
|
||||
db_session = get_session_for_user(user_id)
|
||||
if db_session:
|
||||
try:
|
||||
analyses = (
|
||||
db_session.query(CompetitorAnalysis)
|
||||
.join(
|
||||
OnboardingSession,
|
||||
CompetitorAnalysis.session_id == OnboardingSession.id,
|
||||
)
|
||||
.filter(OnboardingSession.user_id == user_id)
|
||||
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
|
||||
.all()
|
||||
)
|
||||
competitor_domains = list(
|
||||
set(a.competitor_domain for a in analyses if a.competitor_domain)
|
||||
)
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
if not competitor_domains:
|
||||
return {
|
||||
"gaps": [],
|
||||
"message": "No competitor domains found. Complete onboarding Step 3.",
|
||||
}
|
||||
|
||||
# Run SERP gap analysis
|
||||
from services.seo_tools.serp_gap_service import SerpGapService
|
||||
|
||||
service = SerpGapService()
|
||||
result = await service.analyze_topic_gaps(topics, competitor_domains)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get SERP gaps: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get SERP gaps: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
async def get_competitor_content(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
topics: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get competitor content deep-dive for gap topics using Exa.
|
||||
|
||||
Scopes Exa neural search to known competitor domains (from onboarding Step 3)
|
||||
and returns full text, highlights, and summaries for competitive analysis.
|
||||
|
||||
Args:
|
||||
topics: Optional list of topic phrases. If omitted, uses the user's
|
||||
latest SIF semantic gaps (up to 6 topics — Exa is paid).
|
||||
|
||||
Returns:
|
||||
Dict with per-topic competitor content results.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get("id"))
|
||||
|
||||
# If no topics provided, fetch from SIF semantic gaps
|
||||
if not topics:
|
||||
try:
|
||||
from services.intelligence.agents.specialized import StrategyArchitectAgent
|
||||
from services.intelligence.txtai_service import TxtaiIntelligenceService
|
||||
|
||||
integration = OnboardingDataIntegrationService()
|
||||
db_session = get_session_for_user(user_id)
|
||||
if db_session:
|
||||
try:
|
||||
integrated = integration.get_integrated_data_sync(
|
||||
user_id, db_session
|
||||
)
|
||||
competitor_indices = []
|
||||
if integrated and integrated.get("competitor_analysis"):
|
||||
competitor_indices = [
|
||||
i
|
||||
for i, _ in enumerate(
|
||||
integrated["competitor_analysis"]
|
||||
)
|
||||
]
|
||||
agent = StrategyArchitectAgent(
|
||||
TxtaiIntelligenceService(user_id), user_id
|
||||
)
|
||||
gaps = await agent.find_semantic_gaps(competitor_indices)
|
||||
# Fewer topics for Exa (paid API)
|
||||
topics = [g["topic"] for g in gaps[:6]]
|
||||
finally:
|
||||
db_session.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not derive topics from SIF gaps: {e}. "
|
||||
"Pass topics explicitly."
|
||||
)
|
||||
return {
|
||||
"results": [],
|
||||
"message": "No topics provided and unable to derive from SIF gaps.",
|
||||
}
|
||||
|
||||
if not topics:
|
||||
return {
|
||||
"results": [],
|
||||
"message": "No topics to analyze. Complete onboarding and SIF indexing first.",
|
||||
}
|
||||
|
||||
# Get competitor domains from onboarding
|
||||
competitor_domains = []
|
||||
db_session = get_session_for_user(user_id)
|
||||
if db_session:
|
||||
try:
|
||||
analyses = (
|
||||
db_session.query(CompetitorAnalysis)
|
||||
.join(
|
||||
OnboardingSession,
|
||||
CompetitorAnalysis.session_id == OnboardingSession.id,
|
||||
)
|
||||
.filter(OnboardingSession.user_id == user_id)
|
||||
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
|
||||
.all()
|
||||
)
|
||||
competitor_domains = list(
|
||||
set(a.competitor_domain for a in analyses if a.competitor_domain)
|
||||
)
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
if not competitor_domains:
|
||||
return {
|
||||
"results": [],
|
||||
"message": "No competitor domains found. Complete onboarding Step 3.",
|
||||
}
|
||||
|
||||
# Run Exa competitor deep-dive
|
||||
from services.seo_tools.competitor_content_service import (
|
||||
CompetitorContentService,
|
||||
)
|
||||
|
||||
service = CompetitorContentService()
|
||||
result = await service.deep_dive(topics, competitor_domains)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get competitor content: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get competitor content: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
async def get_content_gap_radar(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
bypass_cache: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the Content Gap Radar pipeline — the full Phase 3 agent.
|
||||
|
||||
Orchestrates SIF semantic gap analysis, SERP ranking presence detection,
|
||||
Exa competitor content deep-dive, and trend momentum scoring into a
|
||||
single ROI-ranked list of content opportunities.
|
||||
|
||||
Returns scored gaps with per-topic evidence and a summary.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get("id"))
|
||||
|
||||
# Fetch competitor domains + indices from onboarding data
|
||||
competitor_domains = []
|
||||
competitor_indices = []
|
||||
|
||||
db_session = get_session_for_user(user_id)
|
||||
if db_session:
|
||||
try:
|
||||
# Competitor domains
|
||||
analyses = (
|
||||
db_session.query(CompetitorAnalysis)
|
||||
.join(
|
||||
OnboardingSession,
|
||||
CompetitorAnalysis.session_id == OnboardingSession.id,
|
||||
)
|
||||
.filter(OnboardingSession.user_id == user_id)
|
||||
.filter(CompetitorAnalysis.competitor_domain.isnot(None))
|
||||
.all()
|
||||
)
|
||||
competitor_domains = list(
|
||||
set(
|
||||
a.competitor_domain
|
||||
for a in analyses
|
||||
if a.competitor_domain
|
||||
)
|
||||
)
|
||||
|
||||
# Competitor indices from integrated data
|
||||
integration = OnboardingDataIntegrationService()
|
||||
integrated = integration.get_integrated_data_sync(
|
||||
user_id, db_session
|
||||
)
|
||||
if integrated and integrated.get("competitor_analysis"):
|
||||
competitor_indices = [
|
||||
i
|
||||
for i, _ in enumerate(
|
||||
integrated["competitor_analysis"]
|
||||
)
|
||||
]
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
if not competitor_domains:
|
||||
return {
|
||||
"gaps": [],
|
||||
"summary": {},
|
||||
"message": "No competitor domains found. Complete onboarding Step 3.",
|
||||
}
|
||||
|
||||
# Run the agent
|
||||
from services.intelligence.agents import ContentGapRadarAgent
|
||||
from services.intelligence.txtai_service import TxtaiIntelligenceService
|
||||
|
||||
agent = ContentGapRadarAgent(
|
||||
TxtaiIntelligenceService(user_id), user_id
|
||||
)
|
||||
result = await agent.analyze(
|
||||
competitor_domains=competitor_domains,
|
||||
competitor_indices=competitor_indices,
|
||||
bypass_cache=bypass_cache,
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run content gap radar: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to run content gap radar: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
class GenerateContentRequest(BaseModel):
|
||||
topic: str
|
||||
recommended_action: str = ""
|
||||
scoring: Optional[Dict[str, float]] = None
|
||||
serp_evidence: Optional[Dict[str, Any]] = None
|
||||
sif_gap: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
async def generate_content_from_gap(
|
||||
request: GenerateContentRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a content brief from a content gap radar item and save it
|
||||
as a blog ContentAsset so the user can resume in the Blog Writer.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get("id"))
|
||||
from services.intelligence.agents import ContentGapRadarAgent
|
||||
from services.intelligence.txtai_service import TxtaiIntelligenceService
|
||||
|
||||
agent = ContentGapRadarAgent(
|
||||
TxtaiIntelligenceService(user_id), user_id
|
||||
)
|
||||
brief_result = await agent.generate_content_brief(
|
||||
topic=request.topic,
|
||||
recommended_action=request.recommended_action,
|
||||
scoring=request.scoring,
|
||||
serp_evidence=request.serp_evidence,
|
||||
sif_gap=request.sif_gap,
|
||||
)
|
||||
|
||||
# Create blog ContentAsset so user can resume in Blog Writer
|
||||
from services.content_asset_service import ContentAssetService
|
||||
from models.content_asset_models import AssetType, AssetSource
|
||||
from services.database import get_db_session
|
||||
|
||||
session = get_db_session()
|
||||
asset_id = None
|
||||
if session:
|
||||
try:
|
||||
svc = ContentAssetService(session)
|
||||
asset = svc.create_asset(
|
||||
user_id=user_id,
|
||||
asset_type=AssetType.TEXT,
|
||||
source_module=AssetSource.BLOG_WRITER,
|
||||
filename=f"gap_{int(time.time())}.md",
|
||||
file_url=f"/api/blog/content/pending",
|
||||
title=request.topic,
|
||||
description=f"Content brief from gap analysis: {request.topic}",
|
||||
tags=["content-gap", "seo-dashboard"],
|
||||
asset_metadata={
|
||||
"phase": "research",
|
||||
"research_keywords": request.topic,
|
||||
"topic": request.topic,
|
||||
"research_data": brief_result,
|
||||
"outline_data": None,
|
||||
"content_data": None,
|
||||
"seo_data": None,
|
||||
"publish_data": None,
|
||||
},
|
||||
)
|
||||
asset_id = asset.id
|
||||
logger.info(
|
||||
f"Created blog asset {asset_id} for gap topic '{request.topic}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create blog asset: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"brief": brief_result["brief"],
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate content from gap: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate content brief: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
async def get_onboarding_task_health(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
site_url: Optional[str] = None,
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
import time
|
||||
|
||||
from services.wix_service import WixService
|
||||
from services.integrations.wix_oauth import WixOAuthService
|
||||
@@ -40,25 +41,80 @@ def _get_current_user_id(current_user: dict) -> str:
|
||||
|
||||
|
||||
def _map_wix_error(exc: Exception, fallback: str = "Wix API request failed") -> HTTPException:
|
||||
"""Map Wix API exceptions to proper HTTP responses with actionable guidance."""
|
||||
import traceback
|
||||
|
||||
if isinstance(exc, HTTPException):
|
||||
return exc
|
||||
|
||||
# Try to extract meaningful error from Wix API response
|
||||
wix_error_detail = None
|
||||
wix_error_code = None
|
||||
|
||||
if hasattr(exc, 'response') and exc.response is not None:
|
||||
try:
|
||||
err_body = exc.response.json()
|
||||
if isinstance(err_body, dict):
|
||||
wix_error_detail = err_body.get('message') or err_body.get('error') or err_body.get('details')
|
||||
wix_error_code = err_body.get('code') or err_body.get('errorCode')
|
||||
except:
|
||||
wix_error_detail = exc.response.text[:300] if exc.response.text else None
|
||||
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
msg = str(exc) if str(exc) != "" else fallback
|
||||
msg = wix_error_detail or str(exc) if str(exc) != "" else fallback
|
||||
|
||||
if status == 401:
|
||||
return HTTPException(status_code=401, detail=msg)
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Wix authorization failed. Please reconnect your Wix account."
|
||||
)
|
||||
if status == 403:
|
||||
return HTTPException(status_code=403, detail=msg)
|
||||
return HTTPException(status_code=502, detail=msg)
|
||||
return HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Wix permission denied. Ensure your OAuth app has blog permissions (BLOG.CREATE-DRAFT)."
|
||||
)
|
||||
if status == 404:
|
||||
return HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Wix API endpoint not found. The blog feature may not be enabled on this site."
|
||||
)
|
||||
if status == 429:
|
||||
return HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Wix rate limit exceeded. Please wait a moment and try again."
|
||||
)
|
||||
if status == 500:
|
||||
return HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Wix server error. This is usually temporary — please try again."
|
||||
)
|
||||
if status == 502 or status == 503 or status == 504:
|
||||
return HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Wix service temporarily unavailable. Please try again in a moment."
|
||||
)
|
||||
return HTTPException(status_code=502, detail=msg or fallback)
|
||||
|
||||
if isinstance(exc, requests.RequestException):
|
||||
return HTTPException(status_code=502, detail=str(exc) or fallback)
|
||||
return HTTPException(status_code=500, detail=str(exc))
|
||||
return HTTPException(
|
||||
status_code=502,
|
||||
detail="Network error connecting to Wix. Please check your connection and try again."
|
||||
)
|
||||
|
||||
# For validation errors from blog_publisher
|
||||
error_str = str(exc)
|
||||
if "validation failed" in error_str.lower():
|
||||
return HTTPException(status_code=400, detail=error_str)
|
||||
|
||||
return HTTPException(status_code=500, detail=f"{fallback}: {error_str}")
|
||||
|
||||
|
||||
def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
|
||||
user_id = _get_current_user_id(current_user)
|
||||
tokens = wix_oauth_service.get_user_tokens(user_id)
|
||||
if tokens:
|
||||
logger.info(f"Wix token resolved from DB for user {user_id[:8]}...")
|
||||
return tokens[0]
|
||||
|
||||
token_status = wix_oauth_service.get_user_token_status(user_id)
|
||||
@@ -66,14 +122,25 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
|
||||
if not expired_tokens:
|
||||
raise HTTPException(status_code=401, detail="Wix account not connected")
|
||||
|
||||
MAX_REFRESH_ATTEMPTS = 3
|
||||
attempt = 0
|
||||
for candidate in expired_tokens:
|
||||
if attempt >= MAX_REFRESH_ATTEMPTS:
|
||||
logger.warning(f"Wix token refresh: reached max {MAX_REFRESH_ATTEMPTS} attempts for user {user_id[:8]}...")
|
||||
break
|
||||
refresh_token = candidate.get("refresh_token")
|
||||
token_id = candidate.get("id")
|
||||
if not refresh_token:
|
||||
continue
|
||||
attempt += 1
|
||||
if attempt > 1:
|
||||
backoff = min(2 ** (attempt - 1), 8)
|
||||
logger.info(f"Wix token refresh: attempt {attempt}/{MAX_REFRESH_ATTEMPTS}, waiting {backoff}s...")
|
||||
time.sleep(backoff)
|
||||
try:
|
||||
refreshed = wix_service.refresh_access_token(refresh_token)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Wix token refresh attempt {attempt} failed: {str(exc)[:120]}")
|
||||
continue
|
||||
|
||||
wix_oauth_service.update_tokens(
|
||||
@@ -83,7 +150,7 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
|
||||
expires_in=refreshed.get("expires_in"),
|
||||
token_id=token_id,
|
||||
)
|
||||
|
||||
logger.info(f"Wix token refreshed successfully on attempt {attempt} for user {user_id[:8]}...")
|
||||
return {
|
||||
"access_token": refreshed.get("access_token"),
|
||||
"refresh_token": refreshed.get("refresh_token", refresh_token),
|
||||
@@ -95,9 +162,18 @@ def _resolve_valid_wix_token(current_user: dict) -> Dict[str, Any]:
|
||||
|
||||
|
||||
class WixAuthRequest(BaseModel):
|
||||
"""Request model for Wix authentication"""
|
||||
code: str
|
||||
state: str
|
||||
"""Request model for Wix authentication.
|
||||
Supports two modes:
|
||||
1. Backend exchanges code: requires code + code_verifier
|
||||
2. Frontend already exchanged: provides access_token directly
|
||||
"""
|
||||
code: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
code_verifier: Optional[str] = None
|
||||
access_token: Optional[str] = None
|
||||
refresh_token: Optional[str] = None
|
||||
expires_in: Optional[int] = None
|
||||
token_type: Optional[str] = "Bearer"
|
||||
|
||||
|
||||
class WixPublishRequest(BaseModel):
|
||||
@@ -112,6 +188,7 @@ class WixPublishRequest(BaseModel):
|
||||
publish: bool = True
|
||||
access_token: Optional[str] = None
|
||||
member_id: Optional[str] = None
|
||||
site_id: Optional[str] = None
|
||||
seo_metadata: Optional[Dict[str, Any]] = None
|
||||
class WixCreateCategoryRequest(BaseModel):
|
||||
access_token: str
|
||||
@@ -217,39 +294,91 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found")
|
||||
|
||||
if not request.state:
|
||||
raise HTTPException(status_code=400, detail="Missing OAuth state")
|
||||
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
|
||||
if not code_verifier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired OAuth state. Please restart Wix connection."
|
||||
)
|
||||
# Exchange code for tokens
|
||||
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
expires_in: int | None = None
|
||||
token_type: str = "Bearer"
|
||||
site_info: dict = {}
|
||||
site_id: str | None = None
|
||||
member_id: str | None = None
|
||||
permissions: dict = {}
|
||||
|
||||
# Get site information to extract site_id and member_id
|
||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
# MODE 2: Frontend already exchanged the code (preferred — avoids PKCE verifier mismatch)
|
||||
if request.access_token:
|
||||
logger.info(f"Wix callback mode=FRONTEND_TOKEN for user {user_id}")
|
||||
access_token = request.access_token
|
||||
refresh_token = request.refresh_token
|
||||
expires_in = request.expires_in
|
||||
token_type = request.token_type or "Bearer"
|
||||
|
||||
# Non-fatal enrichment
|
||||
try:
|
||||
site_info = wix_service.get_site_info(access_token)
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
except Exception as e:
|
||||
logger.warning(f"get_site_info failed (non-fatal): {e}")
|
||||
try:
|
||||
member_id = wix_service.extract_member_id_from_access_token(access_token)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
permissions = wix_service.check_blog_permissions(access_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"check_blog_permissions failed (non-fatal): {e}")
|
||||
|
||||
# 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
|
||||
# MODE 1: Backend exchanges code (legacy / requires correct code_verifier)
|
||||
elif request.code:
|
||||
if not request.state:
|
||||
raise HTTPException(status_code=400, detail="Missing OAuth state")
|
||||
code_verifier = request.code_verifier
|
||||
if not code_verifier:
|
||||
code_verifier = wix_oauth_service.consume_pkce_verifier(user_id=user_id, state=request.state)
|
||||
if code_verifier:
|
||||
logger.info(f"Fallback: using DB-stored code_verifier for user {user_id}")
|
||||
if not code_verifier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired OAuth state. Please restart Wix connection."
|
||||
)
|
||||
logger.info(f"Wix callback mode=BACKEND_EXCHANGE for user {user_id}")
|
||||
tokens = wix_service.exchange_code_for_tokens(request.code, code_verifier=code_verifier)
|
||||
logger.info(f"Token exchange succeeded for user {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')
|
||||
|
||||
try:
|
||||
site_info = wix_service.get_site_info(access_token)
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
except Exception as e:
|
||||
logger.warning(f"get_site_info failed (non-fatal): {e}")
|
||||
try:
|
||||
from services.integrations.wix.utils import extract_meta_from_token
|
||||
site_id = extract_meta_from_token(access_token) or site_id
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
member_id = wix_service.extract_member_id_from_access_token(access_token)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
permissions = wix_service.check_blog_permissions(access_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"check_blog_permissions failed (non-fatal): {e}")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Missing code or access_token")
|
||||
|
||||
# Check permissions
|
||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=500, detail="No access_token available")
|
||||
|
||||
# Store tokens securely in 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'),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=expires_in,
|
||||
token_type=token_type,
|
||||
site_id=site_id,
|
||||
member_id=member_id
|
||||
)
|
||||
@@ -260,10 +389,10 @@ async def handle_oauth_callback(request: WixAuthRequest, current_user: dict = De
|
||||
return {
|
||||
"success": True,
|
||||
"tokens": {
|
||||
"access_token": tokens['access_token'],
|
||||
"refresh_token": tokens.get('refresh_token'),
|
||||
"expires_in": tokens.get('expires_in'),
|
||||
"token_type": tokens.get('token_type', 'Bearer')
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_in": expires_in,
|
||||
"token_type": token_type
|
||||
},
|
||||
"site_info": site_info,
|
||||
"permissions": permissions,
|
||||
@@ -288,11 +417,22 @@ async def handle_oauth_callback_get(code: str, state: Optional[str] = None, requ
|
||||
if not code_verifier:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state. Please reconnect Wix.")
|
||||
tokens = wix_service.exchange_code_for_tokens(code, code_verifier=code_verifier)
|
||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||
|
||||
# Non-fatal: get site info and permissions
|
||||
site_info = {}
|
||||
permissions = {}
|
||||
site_id = None
|
||||
try:
|
||||
site_info = wix_service.get_site_info(tokens['access_token'])
|
||||
site_id = site_info.get('siteId') or site_info.get('site_id')
|
||||
except Exception as e:
|
||||
logger.warning(f"GET callback: get_site_info non-fatal: {e}")
|
||||
try:
|
||||
permissions = wix_service.check_blog_permissions(tokens['access_token'])
|
||||
except Exception as e:
|
||||
logger.warning(f"GET callback: check_blog_permissions non-fatal: {e}")
|
||||
|
||||
# Store tokens in database if we have 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'])
|
||||
@@ -406,13 +546,18 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
|
||||
access_token unless they want to override the stored one.
|
||||
"""
|
||||
try:
|
||||
site_id = request.site_id
|
||||
if request.access_token:
|
||||
from services.integrations.wix.utils import normalize_token_string
|
||||
access_token = normalize_token_string(request.access_token)
|
||||
logger.info(f"Wix publish: using frontend-fallback token for user {_get_current_user_id(current_user)[:8]}...")
|
||||
else:
|
||||
try:
|
||||
token_info = _resolve_valid_wix_token(current_user)
|
||||
access_token = token_info["access_token"]
|
||||
if not site_id:
|
||||
site_id = token_info.get("site_id")
|
||||
logger.info(f"Wix publish: using backend DB token for user {_get_current_user_id(current_user)[:8]}...")
|
||||
except HTTPException:
|
||||
access_token = None
|
||||
|
||||
@@ -422,19 +567,41 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
|
||||
"error": "Wix account not connected. Connect your Wix account first.",
|
||||
}
|
||||
|
||||
if not request.content or not request.content.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Content cannot be empty. Please write your blog post before publishing.",
|
||||
}
|
||||
|
||||
content_length = len(request.content.strip())
|
||||
if content_length > 50000:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Content is {content_length // 1000}K characters — maximum is 50K. Please shorten your content.",
|
||||
}
|
||||
|
||||
content_warning = None
|
||||
if content_length > 30000:
|
||||
content_warning = f"Content is {content_length // 1000}K characters. Very long posts may take longer to publish on Wix."
|
||||
logger.warning(f"Wix publish: large content ({content_length} chars) for user {_get_current_user_id(current_user)[:8]}...")
|
||||
|
||||
member_id = request.member_id
|
||||
if not member_id:
|
||||
member_id = wix_service.extract_member_id_from_access_token(access_token)
|
||||
if not member_id:
|
||||
member_info = wix_service.get_current_member(access_token)
|
||||
member_id = (member_info.get("member") or {}).get("id") or member_info.get("id")
|
||||
try:
|
||||
member_info = wix_service.get_current_member(access_token)
|
||||
if member_info and isinstance(member_info, dict):
|
||||
member_id = (member_info.get("member") or {}).get("id") or member_info.get("id")
|
||||
except Exception as e:
|
||||
logger.warning(f"Wix: could not resolve member ID from token: {e}")
|
||||
if not member_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Unable to resolve Wix member ID. Please reconnect your Wix account.",
|
||||
}
|
||||
|
||||
# Resolve categories: accept IDs or names (looked up/created)
|
||||
# Resolve categories/tags: precedence is top-level params > seo_metadata fallback
|
||||
category_ids = request.category_ids or request.category_names
|
||||
tag_ids = request.tag_ids or request.tag_names
|
||||
|
||||
@@ -445,6 +612,9 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
|
||||
if not tag_ids and seo_metadata.get("blog_tags"):
|
||||
tag_ids = seo_metadata.get("blog_tags")
|
||||
|
||||
if seo_metadata.get("url_slug"):
|
||||
logger.info(f"Wix publish: using SEO url_slug for post slug: {seo_metadata.get('url_slug')[:50]}")
|
||||
|
||||
# Ensure category_ids and tag_ids are lists of strings (not ints)
|
||||
if category_ids:
|
||||
category_ids = [str(c) for c in category_ids if c is not None]
|
||||
@@ -461,6 +631,7 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
|
||||
publish=request.publish,
|
||||
member_id=member_id,
|
||||
seo_metadata=seo_metadata,
|
||||
site_id=site_id,
|
||||
)
|
||||
post = result.get("draftPost") or result.get("post") or result
|
||||
raw_url = post.get("url")
|
||||
@@ -474,7 +645,8 @@ async def publish_to_wix(request: WixPublishRequest, current_user: dict = Depend
|
||||
"success": True,
|
||||
"post_id": str(post.get("id", "")),
|
||||
"url": post_url,
|
||||
"publish_state": "PUBLISHED" if request.publish else "DRAFT"
|
||||
"publish_state": "PUBLISHED" if request.publish else "DRAFT",
|
||||
**({"warning": content_warning} if content_warning else {}),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish to Wix: {e}")
|
||||
|
||||
Reference in New Issue
Block a user