Compare commits

...

16 Commits

Author SHA1 Message Date
ajaysi
b894bc0abb fix: GHSA-426f-p74m-73fv — JWT JWKS issuer confusion auth bypass (CVSS 9.4)
Pin issuer and JWKS URL at startup from CLERK_PUBLISHABLE_KEY.
Validate token iss claim before any JWKS fetch.
Add issuer= to jwt.decode() with verify_iss=True.
2026-06-05 12:07:22 +05:30
ajaysi
70542b32fc feat: add linkedin and facebook feature flags, clean up dead code
- Register 'linkedin' FeatureGroup with routers.linkedin and
  api.linkedin_image_generation routers
- Register 'facebook' FeatureGroup with
  api.facebook_writer.routers:facebook_router
- Add 'linkedin' and 'facebook' profiles to PROFILE_GROUP_MAP
- Remove dead imports of linkedin_router, linkedin_image_router,
  and facebook_router from app.py (router manager handles via
  CORE_ROUTER_REGISTRY)
- Add LINKEDIN and FACEBOOK keys to frontend FEATURE_KEYS
- Add route priorities for /linkedin-writer and /facebook-writer
- Change route gates from feature='social' to feature='linkedin'
  and feature='facebook' respectively
2026-06-05 12:07:22 +05:30
ajaysi
9a3d704c5c feat: add backlinking feature flag following blog_writer pattern
- Register 'backlinking' FeatureGroup in feature_registry.py with
  routers=routers.backlink_outreach:router
- Add 'backlinking' profile to PROFILE_GROUP_MAP (core + backlinking)
- Add backlink_outreach to OPTIONAL_ROUTER_REGISTRY with
  features={'all', 'backlinking'}
- Remove direct import/include of backlink_outreach from app.py
  (router manager handles both 'all' and 'backlinking' modes)
- Add BACKLINKING key to FEATURE_KEYS and route priority in
  frontend demoMode.ts
- Change frontend route gate from feature='seo' to feature='backlinking'
  so ALWRITY_ENABLED_FEATURES=backlinking enables the route
2026-06-03 20:19:41 +05:30
ajaysi
8699ffc27d fix: resolve remaining 5 QA audit findings (#3, #8, #10, #11, #12)
#3 — Duplicate prospect handling: add_lead now checks (campaign_id, url)
     before insert; bulk_add_leads skips existing URLs.
#8 — Atomic rate limiting: try_increment_* methods atomically check cap
     and increment in a single session; router uses these before send.
#10 — Reply matching via Message-ID: sender generates Message-ID header,
     stored on OutreachAttempt; reply monitor parses In-Reply-To/References;
     poll_replies matches by message_id first, falls back to from_email.
#11 — Save-to-campaign uses existing store results instead of
      re-running expensive deepDiscover.
#12 — Lead status Literal type: Pydantic models enforce valid status
      values; backend validates via LEAD_VALID_STATUSES frozenset;
      frontend API typed as LeadStatus union.
2026-06-03 20:06:11 +05:30
ajaysi
259194c289 Merge remote-tracking branch 'origin/codex/add-atomic-idempotency-reservation-method'
# Conflicts:
#	backend/routers/backlink_outreach.py
#	backend/services/backlink_outreach_models.py
2026-06-03 18:52:18 +05:30
ajaysi
2f93ae4891 Merge remote-tracking branch 'origin/codex/add-sender-email-validation-and-logging' 2026-06-03 18:50:53 +05:30
ي
bf22a3d318 Handle backlink outreach idempotency reservations 2026-06-03 18:49:14 +05:30
ajaysi
2a879a6e24 Merge remote-tracking branch 'origin/codex/update-compliance-requirements-for-outreach-send' 2026-06-03 18:49:07 +05:30
ajaysi
7749b4db0e Merge remote-tracking branch 'origin/codex/refactor-backlink-outreach-services-for-async-support'
# Conflicts:
#	backend/routers/backlink_outreach.py
2026-06-03 18:49:01 +05:30
ي
cbace3b752 Validate backlink outreach sender aliases 2026-06-03 18:48:17 +05:30
ي
98d4ac6dbd Harden backlink outreach send policy 2026-06-03 18:33:11 +05:30
ي
55b7209554 Refactor backlink discovery HTTP calls 2026-06-03 18:28:40 +05:30
ajaysi
57e46a20f8 Merge remote-tracking branch 'origin/codex/update-backlink-outreach-for-campaign-validation' 2026-06-03 18:22:35 +05:30
ي
ec2f9151b8 Harden backlink lead campaign ownership 2026-06-03 18:19:16 +05:30
ي
40516e5c79 Secure backlink lead status updates 2026-06-03 18:16:10 +05:30
ajaysi
923fa671fe 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
2026-06-01 12:24:31 +05:30
105 changed files with 10082 additions and 3011 deletions

View File

@@ -58,6 +58,21 @@ FEATURE_GROUPS: Dict[str, FeatureGroup] = {
"api.blog_writer.seo_analysis:router",
),
),
"backlinking": FeatureGroup(
features=("backlinking",),
routers=("routers.backlink_outreach:router",),
),
"linkedin": FeatureGroup(
features=("linkedin",),
routers=(
"routers.linkedin:router",
"api.linkedin_image_generation:router",
),
),
"facebook": FeatureGroup(
features=("facebook",),
routers=("api.facebook_writer.routers:facebook_router",),
),
}
@@ -67,5 +82,8 @@ PROFILE_GROUP_MAP: Dict[str, Tuple[str, ...]] = {
"podcast": ("core", "podcast"),
"youtube": ("core", "youtube"),
"blog_writer": ("core", "blog_writer"),
"backlinking": ("core", "backlinking"),
"linkedin": ("core", "linkedin"),
"facebook": ("core", "facebook"),
"planning": ("core", "content_planning"),
}

View File

@@ -67,6 +67,7 @@ OPTIONAL_ROUTER_REGISTRY = [
{"name": "oauth_token_monitoring", "module": "api.oauth_token_monitoring_routes", "attr": "router", "features": {"all", "core"}},
{"name": "agents", "module": "api.agents_api", "attr": "router", "features": {"all"}},
{"name": "today_workflow", "module": "api.today_workflow", "attr": "router", "features": {"all"}},
{"name": "backlink_outreach", "module": "routers.backlink_outreach", "attr": "router", "features": {"all", "backlinking"}},
]
OPTIONAL_MODULE_MATRIX = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,19 +126,14 @@ seo_tools_router = None
if _is_full_mode():
from routers.seo_tools import router as seo_tools_router
# Skip Facebook Writer, LinkedIn, and other non-essential routes in feature-only modes
# Also skip other heavy services that trigger PersonaAnalysisService initialization
# Skip heavy services in feature-only modes (PersonaAnalysisService, etc.)
if _is_full_mode():
from api.facebook_writer.routers import facebook_router
from routers.linkedin import router as linkedin_router
from api.linkedin_image_generation import router as linkedin_image_router
from api.brainstorm import router as brainstorm_router
from api.images import router as images_router
from api.assets_serving import router as assets_serving_router
from routers.image_studio import router as image_studio_router
from routers.product_marketing import router as product_marketing_router
from routers.campaign_creator import router as campaign_creator_router
from routers.backlink_outreach import router as backlink_outreach_router
else:
# In feature-only modes, only load essential assets router
from api.assets_serving import router as assets_serving_router
@@ -147,7 +142,6 @@ else:
image_studio_router = None
product_marketing_router = None
campaign_creator_router = None
backlink_outreach_router = None
# Import hallucination detector router
try:
@@ -683,8 +677,6 @@ if _is_full_mode():
app.include_router(product_marketing_router)
if campaign_creator_router:
app.include_router(campaign_creator_router)
if backlink_outreach_router:
app.include_router(backlink_outreach_router)
router_group_status["platform_extensions"] = {
"mounted": True,
@@ -799,12 +791,31 @@ async def startup_event():
else:
logger.info(f"[FEATURE-MODE] Skipping scheduler startup (features: {enabled_features})")
# Check Wix API key configuration
# Recover stale YouTube tasks on startup
if _is_feature_enabled("youtube"):
try:
from api.youtube.task_manager import task_manager
from services.database import get_all_user_ids
user_ids = get_all_user_ids()
recovered = 0
for uid in user_ids:
try:
count = task_manager.recover_stale_tasks(uid)
recovered += count
except Exception:
pass
if recovered > 0:
logger.info(f"[STARTUP] Recovered {recovered} stale YouTube tasks across {len(user_ids)} users")
except Exception as e:
logger.warning(f"[STARTUP] YouTube task recovery skipped: {e}")
# Check Wix configuration (OAuth-based, API key optional)
wix_api_key = os.getenv('WIX_API_KEY')
if wix_api_key:
logger.warning(f"WIX_API_KEY loaded ({len(wix_api_key)} chars, starts with '{wix_api_key[:10]}...')")
else:
logger.warning("⚠️ WIX_API_KEY not found in environment - Wix publishing may fail")
logger.info(f"WIX_API_KEY loaded ({len(wix_api_key)} chars)")
wix_client_id = os.getenv('WIX_CLIENT_ID')
if not wix_client_id:
logger.warning("⚠️ WIX_CLIENT_ID not found in environment - Wix OAuth connection will fail")
elapsed = time.time() - startup_start
logger.info(f"ALwrity backend started successfully in {elapsed:.1f}s")

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

View File

@@ -13,7 +13,7 @@ builtins.Union = typing.Union
from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks
from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -137,6 +137,11 @@ from api.seo_dashboard import (
get_sif_indexing_health,
get_guardian_audit,
get_keyword_gaps,
get_serp_gaps,
get_competitor_content,
get_content_gap_radar,
generate_content_from_gap,
GenerateContentRequest,
)
# Initialize FastAPI app
@@ -391,6 +396,64 @@ async def keyword_gaps_endpoint(
return await get_keyword_gaps(current_user, site_url)
@app.get("/api/seo-dashboard/serp-gaps")
async def serp_gaps_endpoint(
current_user: dict = Depends(get_current_user),
topics: Optional[List[str]] = None,
):
"""
Get SERP gap analysis — detect which competitors rank for given topics.
Uses Google Custom Search `site:` queries per competitor domain to detect
ranking presence. If no topics are provided, derives them from the user's
latest SIF semantic gap analysis (up to 12 topics).
"""
return await get_serp_gaps(current_user, topics)
@app.get("/api/seo-dashboard/competitor-content")
async def competitor_content_endpoint(
current_user: dict = Depends(get_current_user),
topics: Optional[List[str]] = None,
):
"""
Get competitor content deep-dive for gap topics using Exa.
Scopes Exa neural search to known competitor domains and returns
full text, highlights, and summaries for competitive analysis.
If no topics provided, derives up to 6 from the latest SIF semantic gaps.
"""
return await get_competitor_content(current_user, topics)
@app.get("/api/seo-dashboard/content-gap-radar")
async def content_gap_radar_endpoint(
current_user: dict = Depends(get_current_user),
bypass_cache: bool = Query(False, description="Bypass 24h cache"),
):
"""
Run the Content Gap Radar pipeline — full Phase 3 agent.
Orchestrates SIF semantic gap analysis, SERP ranking presence (Google CSE),
competitor content deep-dive (Exa), and trend momentum scoring into a single
ROI-ranked list of content opportunities.
"""
return await get_content_gap_radar(current_user, bypass_cache=bypass_cache)
@app.post("/api/seo-dashboard/content-gap-radar/generate-content")
async def generate_content_from_gap_endpoint(
request: GenerateContentRequest,
current_user: dict = Depends(get_current_user),
):
"""
Generate a content brief from a content gap radar item and save it
as a blog ContentAsset. Navigate to /blog-writer with the returned
asset_id to resume in the full Blog Writer workflow.
"""
return await generate_content_from_gap(request, current_user)
# Comprehensive SEO Analysis endpoints
@app.post("/api/seo-dashboard/analyze-comprehensive")
async def analyze_seo_comprehensive_endpoint(request: SEOAnalysisRequest):

View File

@@ -50,6 +50,7 @@ class ClerkAuthMiddleware:
# Cache for PyJWKClient to avoid repeated JWKS fetches
self._jwks_client_cache = {}
self._jwks_url_cache = None
self._issuer_cache = None # Pre-configured Clerk issuer for iss validation
if not self.clerk_secret_key and not self.disable_auth:
logger.warning("CLERK_SECRET_KEY not found, authentication may fail")
@@ -58,15 +59,16 @@ class ClerkAuthMiddleware:
if CLERK_AUTH_AVAILABLE and not self.disable_auth:
try:
if self.clerk_secret_key and self.clerk_publishable_key:
# Extract instance from publishable key for JWKS URL
# Extract instance from publishable key for JWKS URL and issuer validation
# Format: pk_test_<instance>.<domain> or pk_live_<instance>.<domain>
parts = self.clerk_publishable_key.replace('pk_test_', '').replace('pk_live_', '').split('.')
if len(parts) >= 1:
# Extract the domain from publishable key or use default
# Clerk URLs are typically: https://<instance>.clerk.accounts.dev
instance = parts[0]
jwks_url = f"https://{instance}.clerk.accounts.dev/.well-known/jwks.json"
issuer_url = f"https://{instance}.clerk.accounts.dev"
jwks_url = f"{issuer_url}/.well-known/jwks.json"
# Create Clerk configuration with JWKS URL
clerk_config = ClerkConfig(
secret_key=self.clerk_secret_key,
@@ -76,6 +78,7 @@ class ClerkAuthMiddleware:
self.clerk_bearer = ClerkHTTPBearer(clerk_config)
logger.info(f"fastapi-clerk-auth initialized successfully with JWKS URL: {jwks_url}")
self._jwks_url_cache = jwks_url
self._issuer_cache = issuer_url # Pin issuer for VULN-001 fix
else:
logger.warning("Could not extract instance from publishable key")
self.clerk_bearer = None
@@ -118,19 +121,29 @@ class ClerkAuthMiddleware:
import jwt
from jwt import PyJWKClient
# Get the JWKS URL from the token header
# Get the unverified header for key ID lookup
unverified_header = jwt.get_unverified_header(token)
# Decode token to get issuer for JWKS URL
# --- SECURITY FIX (VULN-001): Validate issuer before any JWKS fetch ---
# Pre-configured issuer and JWKS URL derived from CLERK_PUBLISHABLE_KEY
# NEVER use the token's 'iss' claim to construct the JWKS URL (GHSA-426f-p74m-73fv)
expected_issuer = self._issuer_cache
jwks_url = self._jwks_url_cache
if not expected_issuer or not jwks_url:
raise Exception("Clerk issuer/JWKS URL not configured at startup")
# Decode token to validate the issuer claim against the pre-configured value
# WARNING: We must first validate 'iss' before trusting anything else
unverified_claims = jwt.decode(token, options={"verify_signature": False})
issuer = unverified_claims.get('iss', '')
# Construct JWKS URL from issuer
jwks_url = f"{issuer}/.well-known/jwks.json" if issuer else self._jwks_url_cache or ""
if not jwks_url:
raise Exception("Unable to resolve JWKS URL for Clerk verification")
# Use cached PyJWKClient to avoid repeated JWKS fetches
token_issuer = unverified_claims.get('iss', '')
if token_issuer != expected_issuer:
logger.error(
f"Issuer mismatch: token claims '{token_issuer}' "
f"but expected '{expected_issuer}'"
)
return None
# Use cached PyJWKClient with pinned jwks_url (never derived from token)
if jwks_url not in self._jwks_client_cache:
logger.info(f"Creating new PyJWKClient for {jwks_url} with caching enabled")
# Create client with caching enabled (cache_keys=True keeps keys in memory)
@@ -139,17 +152,19 @@ class ClerkAuthMiddleware:
cache_keys=True,
max_cached_keys=16
)
jwks_client = self._jwks_client_cache[jwks_url]
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Verify and decode the token with clock skew tolerance
# Add 300 seconds (5 minutes) leeway to handle clock skew and token refresh delays
# SECURITY: Always pass issuer= to verify the token's 'iss' matches expected (VULN-001)
decoded_token = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
options={"verify_signature": True, "verify_exp": True},
issuer=expected_issuer,
options={"verify_signature": True, "verify_exp": True, "verify_iss": True},
leeway=300 # Allow 5 minutes leeway for token refresh during navigation
)

View File

@@ -46,6 +46,7 @@ class OutreachAttempt(Base):
decision_reason = Column(Text, nullable=True)
sent_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
message_id = Column(String(255), nullable=True, index=True)
class OutreachReply(Base):

View File

@@ -318,7 +318,7 @@ class SIFIndexingTask(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String(255), nullable=False, index=True)
website_url = Column(String(500), nullable=False, index=True)
website_url = Column(String(500), nullable=True, index=True)
status = Column(String(50), default='active', index=True)
@@ -331,7 +331,7 @@ class SIFIndexingTask(Base):
failure_pattern = Column(JSON, nullable=True)
next_execution = Column(DateTime, nullable=True, index=True)
frequency_hours = Column(Integer, default=48) # Default 48 hours
frequency_hours = Column(Integer, default=48)
payload = Column(JSON, nullable=True)
@@ -346,6 +346,7 @@ class SIFIndexingTask(Base):
__table_args__ = (
Index('idx_sif_indexing_tasks_user_site', 'user_id', 'website_url'),
Index('idx_sif_indexing_tasks_user_only', 'user_id'),
Index('idx_sif_indexing_tasks_next_execution', 'next_execution'),
Index('idx_sif_indexing_tasks_status', 'status'),
)
@@ -387,7 +388,7 @@ class MarketTrendsTask(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String(255), nullable=False, index=True)
website_url = Column(String(500), nullable=False, index=True)
website_url = Column(String(500), nullable=True, index=True)
status = Column(String(50), default="active", index=True)
@@ -415,6 +416,7 @@ class MarketTrendsTask(Base):
__table_args__ = (
Index("idx_market_trends_tasks_user_site", "user_id", "website_url"),
Index("idx_market_trends_tasks_user_only", "user_id"),
Index("idx_market_trends_tasks_next_execution", "next_execution"),
Index("idx_market_trends_tasks_status", "status"),
)

View File

@@ -22,7 +22,10 @@ from services.backlink_outreach_models import (
SuppressionAddRequest,
)
from services.backlink_outreach_service import backlink_outreach_service
from services.backlink_outreach_storage import BacklinkOutreachStorageService
from services.backlink_outreach_storage import (
BacklinkCampaignNotFoundError,
BacklinkOutreachStorageService,
)
from services.backlink_outreach_sender import backlink_outreach_sender
from services.backlink_outreach_reply_monitor import backlink_outreach_reply_monitor
from services.backlink_outreach_template_generator import (
@@ -68,7 +71,7 @@ async def discover_backlink_opportunities(
payload: BacklinkKeywordInput,
current_user: Dict[str, Any] = Depends(get_current_user),
):
return backlink_outreach_service.discover_opportunities(payload.keyword, payload.max_results)
return await backlink_outreach_service.discover_opportunities_async(payload.keyword, payload.max_results)
@router.get("/migration-coverage")
@@ -84,12 +87,25 @@ async def get_backlink_migration_coverage(
async def discover_deep_backlink_opportunities(
payload: DeepKeywordInput,
current_user: Dict[str, Any] = Depends(get_current_user),
scrape_timeout_seconds: float = Query(15.0, ge=1.0, le=60.0),
scrape_max_concurrency: int = Query(5, ge=1, le=20),
):
"""Enhanced discovery using Exa neural search + DuckDuckGo with full-page scraping."""
user_id = _resolve_user_id(current_user)
result = await backlink_outreach_service.deep_discover(payload.keyword, payload.max_results)
storage = None
if payload.campaign_id:
storage = BacklinkOutreachStorageService()
if not storage.get_campaign(payload.campaign_id, user_id):
raise HTTPException(status_code=404, detail="Campaign not found")
result = await backlink_outreach_service.deep_discover(
payload.keyword,
payload.max_results,
user_id=user_id,
scrape_timeout_seconds=scrape_timeout_seconds,
scrape_max_concurrency=scrape_max_concurrency,
)
if payload.campaign_id:
saved = 0
save_failed = 0
for opp in result.get("opportunities", []):
@@ -183,7 +199,9 @@ async def add_campaign_lead(
notes=payload.notes,
)
return lead
except Exception as e:
except BacklinkCampaignNotFoundError:
raise HTTPException(status_code=404, detail="Campaign not found")
except Exception:
raise HTTPException(status_code=500, detail="Failed to add lead")
@@ -192,18 +210,48 @@ async def bulk_update_lead_status(
payload: BulkStatusUpdateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Bulk update lead statuses."""
"""Bulk update lead statuses for leads owned by the current user."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
access_issues = storage.get_lead_access_issues(
payload.lead_ids, user_id, campaign_id=payload.campaign_id
)
if access_issues["unauthorized"]:
raise HTTPException(
status_code=403,
detail={
"message": "One or more leads do not belong to the current user",
"lead_ids": access_issues["unauthorized"],
},
)
if access_issues["missing"]:
raise HTTPException(
status_code=404,
detail={
"message": "One or more leads were not found",
"lead_ids": access_issues["missing"],
},
)
updated = 0
failed: list[str] = []
for lid in payload.lead_ids:
try:
lead = storage.update_lead_status(lid, user_id, payload.status, payload.notes)
lead = storage.update_lead_status(
lid,
user_id,
payload.status,
payload.notes,
campaign_id=payload.campaign_id,
)
if lead:
updated += 1
else:
failed.append(lid)
except PermissionError:
raise HTTPException(
status_code=403, detail="Lead does not belong to the current user"
)
except Exception:
failed.append(lid)
return BulkStatusUpdateResponse(updated=updated, failed=failed)
@@ -218,7 +266,18 @@ async def update_lead_status(
"""Update lead status (discovered -> contacted -> replied -> placed)."""
user_id = _resolve_user_id(current_user)
storage = BacklinkOutreachStorageService()
lead = storage.update_lead_status(lead_id, user_id, payload.status, payload.notes)
try:
lead = storage.update_lead_status(
lead_id,
user_id,
payload.status,
payload.notes,
campaign_id=payload.campaign_id,
)
except PermissionError:
raise HTTPException(
status_code=403, detail="Lead does not belong to the current user"
)
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
return lead
@@ -260,42 +319,95 @@ async def send_outreach(
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), variables)
result = backlink_outreach_service.send_outreach(
SendOutreachRequest(
lead_id=payload.lead_id,
campaign_id=payload.campaign_id,
user_id=user_id,
workspace_id=payload.workspace_id,
sender_email=payload.sender_email,
subject=subject,
body=body,
idempotency_key=payload.idempotency_key,
sender_validation = backlink_outreach_sender.validate_sender_alias(payload.sender_email)
if not sender_validation.authorized:
return SendOutreachResponse(
attempt_id="",
status="failed",
policy_allowed=False,
policy_reasons=sender_validation.failure_reasons,
effective_sender_email=sender_validation.effective_sender_email or None,
)
)
try:
result = backlink_outreach_service.send_outreach(
SendOutreachRequest(
lead_id=payload.lead_id,
campaign_id=payload.campaign_id,
user_id=user_id,
workspace_id=payload.workspace_id,
sender_email=sender_validation.effective_sender_email,
subject=subject,
body=body,
idempotency_key=payload.idempotency_key,
sender_identity=payload.sender_identity,
legal_basis=payload.legal_basis,
contact_discovery_source=payload.contact_discovery_source,
recipient_region=payload.recipient_region,
recipient_region_source=payload.recipient_region_source,
consent_status=payload.consent_status,
approved_by_human=payload.approved_by_human,
unsubscribe_url=payload.unsubscribe_url,
one_click_unsubscribe=payload.one_click_unsubscribe,
)
)
except Exception:
existing = storage.get_attempt_by_idempotency_key(payload.idempotency_key, user_id=user_id)
if existing:
result = backlink_outreach_service.response_from_attempt(existing, duplicate=True)
if sender_validation.effective_sender_email:
result.effective_sender_email = sender_validation.effective_sender_email
return result
raise HTTPException(status_code=409, detail="Unable to reserve idempotency key")
result.effective_sender_email = sender_validation.effective_sender_email
lead_email = ""
if result.attempt_id:
if result.attempt_id and result.status == "approved" and not result.duplicate:
lead = storage.get_lead(payload.lead_id, user_id=user_id)
lead_email = (lead.get("email") or "") if lead else ""
if result.policy_allowed and lead_email:
sent = await backlink_outreach_sender.send_email(
to_email=lead_email,
subject=subject,
body=body,
)
status = "sent" if sent else "failed"
storage.update_attempt_status(result.attempt_id, status, user_id=user_id)
result.status = status
if sent:
storage.mark_idempotency(payload.idempotency_key, user_id)
storage.increment_user_send_counter(user_id)
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
storage.increment_domain_send_counter(domain, user_id=user_id)
elif result.policy_allowed and not lead_email:
storage.update_attempt_status(result.attempt_id, "failed", user_id=user_id)
if result.status == "approved" and result.policy_allowed and not result.duplicate and lead_email:
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"
user_within_cap, _ = storage.try_increment_user_send_counter(user_id)
domain_within_cap, _ = storage.try_increment_domain_send_counter(domain, user_id=user_id)
if not (user_within_cap and domain_within_cap):
reasons = []
if not user_within_cap:
reasons.append("user_daily_cap_exceeded")
if not domain_within_cap:
reasons.append("domain_daily_cap_exceeded")
reason_str = f"rate_limit_hit; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.update_attempt_status(result.attempt_id, "blocked", decision_reason=reason_str, user_id=user_id)
result.status = "blocked"
result.policy_reasons = reasons
else:
send_result = await backlink_outreach_sender.send_email(
to_email=lead_email,
subject=subject,
body=body,
from_email=payload.sender_email,
)
if send_result.success:
storage.update_attempt_status(result.attempt_id, "sent", user_id=user_id)
result.status = "sent"
result.effective_sender_email = send_result.effective_sender_email or result.effective_sender_email
if send_result.message_id:
storage.update_attempt_message_id(result.attempt_id, send_result.message_id, user_id=user_id)
storage.mark_idempotency(payload.idempotency_key, user_id)
else:
reason = f"smtp_send_failed; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id)
result.status = "failed"
result.policy_reasons = ["smtp_send_failed"]
result.retry_policy = backlink_outreach_service.SMTP_RETRY_POLICY
elif result.status == "approved" and result.policy_allowed and not result.duplicate and not lead_email:
reason = f"lead_has_no_email; retry_policy={backlink_outreach_service.SMTP_RETRY_POLICY}"
storage.update_attempt_status(result.attempt_id, "failed", decision_reason=reason, user_id=user_id)
result.status = "failed"
result.policy_reasons = (result.policy_reasons or []) + ["lead_has_no_email"]
result.retry_policy = backlink_outreach_service.SMTP_RETRY_POLICY
return result
@@ -350,7 +462,18 @@ async def poll_replies(
if storage.reply_exists(from_email, subject, user_id=user_id):
skipped += 1
continue
attempt_id = storage.find_attempt_by_from_email(from_email, user_id=user_id) or ""
attempt_id = ""
in_reply_to = raw.get("in_reply_to", "")
references = raw.get("references", "")
if in_reply_to:
attempt_id = storage.find_attempt_by_message_id(in_reply_to, user_id=user_id) or ""
if not attempt_id and references:
mid = references.split()[-1]
attempt_id = storage.find_attempt_by_message_id(mid, user_id=user_id) or ""
if not attempt_id:
attempt_id = storage.find_attempt_by_from_email(from_email, user_id=user_id) or ""
reply = storage.add_reply(
attempt_id=attempt_id,
from_email=from_email,

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
from pydantic import BaseModel, Field, HttpUrl, EmailStr
from pydantic import BaseModel, Field, HttpUrl
from typing import Dict, List, Optional
from typing_extensions import Literal
class BacklinkKeywordInput(BaseModel):
@@ -10,7 +11,7 @@ class BacklinkKeywordInput(BaseModel):
class OpportunityContactInfo(BaseModel):
email: Optional[EmailStr] = None
email: Optional[str] = None
contact_page: Optional[HttpUrl] = None
@@ -93,8 +94,9 @@ class LeadListResponse(BaseModel):
class LeadStatusUpdateRequest(BaseModel):
status: str = Field(..., min_length=1)
status: Literal["discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"]
notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1)
class CampaignDetailResponse(BaseModel):
@@ -148,6 +150,21 @@ class OutreachStatusRecord(BaseModel):
notes: Optional[str] = None
class SenderIdentity(BaseModel):
name: str = Field(default="", description="Human sender name displayed to the recipient")
email: str = Field(default="")
organization: str = Field(default="", description="Organization or brand responsible for the outreach")
physical_mailing_address: str = Field(default="", description="Postal address required for commercial outreach compliance")
reply_to_email: Optional[str] = Field(None, description="Optional reply-to mailbox if different from sender email")
class OneClickUnsubscribe(BaseModel):
enabled: bool = Field(default=False)
mailto: Optional[str] = Field(None, description="Mailbox for one-click unsubscribe requests")
header_value: Optional[str] = Field(None, description="List-Unsubscribe / one-click unsubscribe header value")
class SendOutreachRequest(BaseModel):
lead_id: str = Field(..., min_length=1)
campaign_id: str = Field(..., min_length=1)
@@ -157,6 +174,15 @@ class SendOutreachRequest(BaseModel):
subject: str = Field(..., min_length=1)
body: str = Field(..., min_length=1)
idempotency_key: str = Field(..., min_length=8)
sender_identity: Optional[SenderIdentity] = None
legal_basis: str = Field(default="")
contact_discovery_source: str = Field(default="")
recipient_region: str = Field(default="unknown")
recipient_region_source: str = Field(default="user_attested", min_length=2)
consent_status: str = Field(default="unknown", min_length=2)
approved_by_human: bool = False
unsubscribe_url: Optional[HttpUrl] = None
one_click_unsubscribe: Optional[OneClickUnsubscribe] = None
template_id: Optional[str] = Field(None, description="Optional template ID for personalization")
template_variables: Optional[dict] = Field(None, description="Variable values for template personalization")
@@ -166,6 +192,9 @@ class SendOutreachResponse(BaseModel):
status: str
policy_allowed: bool
policy_reasons: List[str] = Field(default_factory=list)
effective_sender_email: Optional[str] = None
duplicate: bool = False
retry_policy: Optional[str] = None
class OutreachAttemptRecord(BaseModel):
@@ -240,10 +269,15 @@ class PolicyValidationRequest(BaseModel):
recipient_email: str = Field(..., min_length=1)
recipient_domain: str
recipient_region: str = Field(default="unknown")
legal_basis: str = Field(..., min_length=2)
recipient_region_source: str = Field(default="user_attested", min_length=2)
legal_basis: str = Field(default="")
contact_discovery_source: str = Field(default="")
consent_status: str = Field(default="unknown", min_length=2)
approved_by_human: bool = False
unsubscribe_url: Optional[HttpUrl] = None
sender_identity: str = Field(..., min_length=3)
one_click_unsubscribe: Optional[OneClickUnsubscribe] = None
sender_identity: Optional[SenderIdentity] = None
sender_email: Optional[str] = Field(None, description="Transport sender email, if separate from identity")
idempotency_key: str = Field(..., min_length=8)
@@ -296,8 +330,9 @@ class ConversionFunnelResponse(BaseModel):
class BulkStatusUpdateRequest(BaseModel):
lead_ids: List[str] = Field(..., min_length=1)
status: str = Field(..., min_length=1)
status: Literal["discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"]
notes: Optional[str] = None
campaign_id: Optional[str] = Field(default=None, min_length=1)
class BulkStatusUpdateResponse(BaseModel):

View File

@@ -104,6 +104,8 @@ class BacklinkOutreachReplyMonitor:
from_email = parsed_msg.get("From", "")
subject = parsed_msg.get("Subject", "")
received_at = parsed_msg.get("Date", "")
in_reply_to = parsed_msg.get("In-Reply-To", "")
references = parsed_msg.get("References", "")
# Extract body
body = ""
@@ -137,6 +139,8 @@ class BacklinkOutreachReplyMonitor:
"body": body[:5000],
"classification": classification,
"received_at": received_at_iso,
"in_reply_to": in_reply_to,
"references": references,
}
except Exception as e:
logger.error(f"Failed to parse reply: {e}")

View File

@@ -8,11 +8,10 @@ from __future__ import annotations
import asyncio
import re
import time
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from urllib.parse import quote, urlparse
import requests
import httpx
from bs4 import BeautifulSoup
from loguru import logger
@@ -34,26 +33,47 @@ class BacklinkOutreachScraper:
# -- Public API --
async def deep_discover(
self, keyword: str, max_results: int = 15
self,
keyword: str,
max_results: int = 15,
scrape_timeout_seconds: float = 15.0,
scrape_max_concurrency: int = 5,
) -> Dict[str, Any]:
"""Discover guest-post opportunities using Exa, falling back to DuckDuckGo."""
if self._is_exa_available():
logger.info(f"[BacklinkScraper] Using Exa for keyword: {keyword}")
return await self._discover_with_exa(keyword, max_results)
logger.info(f"[BacklinkScraper] Exa unavailable, falling back to DuckDuckGo for: {keyword}")
return await self._discover_with_duckduckgo(keyword, max_results)
return await self._discover_with_duckduckgo(
keyword,
max_results,
scrape_timeout_seconds=scrape_timeout_seconds,
scrape_max_concurrency=scrape_max_concurrency,
)
def scrape_urls(self, urls: List[str]) -> List[Dict[str, Any]]:
"""Fetch full page content for a list of URLs using Exa get_contents."""
async def scrape_urls(
self,
urls: List[str],
timeout_seconds: float = 15.0,
max_concurrency: int = 5,
) -> List[Dict[str, Any]]:
"""Fetch full page content with non-blocking fallbacks and bounded concurrency."""
exa = self._get_exa_sdk()
if not exa:
return self._scrape_urls_fallback(urls)
return await self._scrape_urls_fallback(
urls, timeout_seconds=timeout_seconds, max_concurrency=max_concurrency
)
loop = asyncio.get_running_loop()
try:
result = exa.get_contents(urls, text={"max_characters": 5000})
result = await loop.run_in_executor(
None, lambda: exa.get_contents(urls, text={"max_characters": 5000})
)
return self._parse_get_contents_result(result)
except Exception as e:
logger.warning(f"[BacklinkScraper] Exa get_contents failed: {e}")
return self._scrape_urls_fallback(urls)
return await self._scrape_urls_fallback(
urls, timeout_seconds=timeout_seconds, max_concurrency=max_concurrency
)
# -- Availability --
@@ -207,24 +227,35 @@ class BacklinkOutreachScraper:
# -- DuckDuckGo Fallback Discovery --
async def _discover_with_duckduckgo(self, keyword: str, max_results: int) -> Dict[str, Any]:
async def _discover_with_duckduckgo(
self,
keyword: str,
max_results: int,
scrape_timeout_seconds: float = 15.0,
scrape_max_concurrency: int = 5,
) -> Dict[str, Any]:
queries = self._generate_search_queries(keyword)
dedup: Dict[str, Dict[str, Any]] = {}
for query in queries[:4]:
rows = self._duckduckgo_search(query)
for row in rows:
norm_url = self._normalize_url(row.get("url", ""))
if not norm_url or norm_url in dedup:
continue
dedup[norm_url] = row
if len(dedup) >= max_results:
break
time.sleep(0.4)
async with httpx.AsyncClient(timeout=httpx.Timeout(12.0), follow_redirects=True) as client:
for query in queries[:4]:
rows = await self._duckduckgo_search(query, client=client)
for row in rows:
norm_url = self._normalize_url(row.get("url", ""))
if not norm_url or norm_url in dedup:
continue
dedup[norm_url] = row
if len(dedup) >= max_results:
break
await asyncio.sleep(0.4)
# Scrape discovered URLs with Exa get_contents (or fallback)
urls_to_scrape = list(dedup.keys())[:max_results]
scraped = self.scrape_urls(urls_to_scrape)
scraped = await self.scrape_urls(
urls_to_scrape,
timeout_seconds=scrape_timeout_seconds,
max_concurrency=scrape_max_concurrency,
)
scraped_map = {self._normalize_url(s.get("url", "")): s for s in scraped}
# Merge DDG results with scraped content
@@ -250,51 +281,76 @@ class BacklinkOutreachScraper:
"opportunities": opportunities,
}
def _duckduckgo_search(self, query: str, retries: int = 2) -> List[Dict[str, Any]]:
encoded = requests.utils.quote(query)
async def _duckduckgo_search(
self,
query: str,
retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> List[Dict[str, Any]]:
encoded = quote(query)
url = f"https://duckduckgo.com/html/?q={encoded}"
headers = {"User-Agent": "Mozilla/5.0 ALwrityBacklinkBot/1.0"}
for attempt in range(retries + 1):
try:
resp = requests.get(url, headers=headers, timeout=12)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for result in soup.select("div.result")[:10]:
anchor = result.select_one("a.result__a")
snippet_el = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
if not anchor or not anchor.get("href"):
continue
results.append({
"url": anchor.get("href"),
"title": anchor.get_text(strip=True),
"snippet": snippet_el.get_text(" ", strip=True) if snippet_el else "",
"highlights": [],
})
return results
except Exception:
if attempt == retries:
return []
time.sleep(0.6 * (attempt + 1))
return []
def _scrape_urls_fallback(self, urls: List[str]) -> List[Dict[str, Any]]:
"""Basic HTTP scrape when Exa is unavailable."""
results = []
async def _request(active_client: httpx.AsyncClient) -> List[Dict[str, Any]]:
for attempt in range(retries + 1):
try:
resp = await active_client.get(url, headers=headers)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for result in soup.select("div.result")[:10]:
anchor = result.select_one("a.result__a")
snippet_el = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
if not anchor or not anchor.get("href"):
continue
results.append({
"url": anchor.get("href"),
"title": anchor.get_text(strip=True),
"snippet": snippet_el.get_text(" ", strip=True) if snippet_el else "",
"highlights": [],
})
return results
except (httpx.HTTPError, httpx.TimeoutException):
if attempt == retries:
return []
await asyncio.sleep(0.6 * (attempt + 1))
return []
if client is not None:
return await _request(client)
async with httpx.AsyncClient(timeout=httpx.Timeout(12.0), follow_redirects=True) as owned_client:
return await _request(owned_client)
async def _scrape_urls_fallback(
self,
urls: List[str],
timeout_seconds: float = 15.0,
max_concurrency: int = 5,
) -> List[Dict[str, Any]]:
"""Basic async HTTP scrape when Exa is unavailable."""
headers = {"User-Agent": "Mozilla/5.0 ALwrityBacklinkBot/1.0"}
for url in urls[:5]:
try:
resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator=" ", strip=True)
title = soup.title.get_text(strip=True) if soup.title else ""
results.append({"url": url, "title": title, "text": text[:5000], "highlights": [], "summary": ""})
except Exception:
continue
return results
semaphore = asyncio.Semaphore(max(1, max_concurrency))
timeout = httpx.Timeout(timeout_seconds)
async def scrape_one(client: httpx.AsyncClient, url: str) -> Optional[Dict[str, Any]]:
async with semaphore:
try:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator=" ", strip=True)
title = soup.title.get_text(strip=True) if soup.title else ""
return {"url": url, "title": title, "text": text[:5000], "highlights": [], "summary": ""}
except (httpx.HTTPError, httpx.TimeoutException):
return None
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
tasks = [scrape_one(client, url) for url in urls]
scraped = await asyncio.gather(*tasks)
return [row for row in scraped if row]
# -- Enrichment Pipeline --

View File

@@ -6,9 +6,11 @@ import os
import ssl
import smtplib
import asyncio
from dataclasses import dataclass, field
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from typing import List, Optional, Set
from uuid import uuid4
from loguru import logger
@@ -17,11 +19,27 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USERNAME)
SMTP_ALLOWED_FROM_EMAILS = os.getenv("SMTP_ALLOWED_FROM_EMAILS", "")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes")
SMTP_VERIFY_TLS = os.getenv("SMTP_VERIFY_TLS", "true").lower() in ("true", "1", "yes")
SMTP_SEND_TIMEOUT = int(os.getenv("SMTP_SEND_TIMEOUT", "30"))
@dataclass
class SenderAuthorizationResult:
authorized: bool
effective_sender_email: str = ""
failure_reasons: List[str] = field(default_factory=list)
@dataclass
class SendEmailResult:
success: bool
effective_sender_email: str = ""
message_id: str = ""
failure_reasons: List[str] = field(default_factory=list)
class BacklinkOutreachSender:
def __init__(self):
self._host = SMTP_HOST
@@ -29,6 +47,7 @@ class BacklinkOutreachSender:
self._username = SMTP_USERNAME
self._password = SMTP_PASSWORD
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
self._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS
self._use_tls = SMTP_USE_TLS
self._verify_tls = SMTP_VERIFY_TLS
self._timeout = SMTP_SEND_TIMEOUT
@@ -36,23 +55,75 @@ class BacklinkOutreachSender:
def is_configured(self) -> bool:
return bool(self._username and self._password)
@staticmethod
def _normalize_email(email: Optional[str]) -> str:
return (email or "").strip().lower()
def _allowed_sender_aliases(self) -> Set[str]:
aliases = {
self._normalize_email(alias)
for alias in self._allowed_from_emails.split(",")
if self._normalize_email(alias)
}
for configured_sender in (self._from_email, self._username):
normalized = self._normalize_email(configured_sender)
if normalized:
aliases.add(normalized)
return aliases
def validate_sender_alias(self, from_email: Optional[str] = None) -> SenderAuthorizationResult:
default_sender = self._normalize_email(self._from_email or self._username)
requested_sender = self._normalize_email(from_email) or default_sender
if not self.is_configured():
return SenderAuthorizationResult(
authorized=False,
effective_sender_email=requested_sender,
failure_reasons=["smtp_not_configured"],
)
if not requested_sender:
return SenderAuthorizationResult(
authorized=False,
failure_reasons=["smtp_sender_missing"],
)
allowed_aliases = self._allowed_sender_aliases()
if requested_sender not in allowed_aliases:
return SenderAuthorizationResult(
authorized=False,
effective_sender_email=requested_sender,
failure_reasons=["sender_alias_not_authorized"],
)
return SenderAuthorizationResult(
authorized=True,
effective_sender_email=requested_sender,
)
async def send_email(
self,
to_email: str,
subject: str,
body: str,
from_email: Optional[str] = None,
) -> bool:
if not self.is_configured():
logger.error("SMTP not configured: set SMTP_USERNAME and SMTP_PASSWORD")
return False
) -> SendEmailResult:
sender_validation = self.validate_sender_alias(from_email)
if not sender_validation.authorized:
logger.error(f"SMTP sender validation failed: {sender_validation.failure_reasons}")
return SendEmailResult(
success=False,
effective_sender_email=sender_validation.effective_sender_email,
failure_reasons=sender_validation.failure_reasons,
)
sender = from_email or self._from_email
sender = sender_validation.effective_sender_email
msg_id = f"<{uuid4().hex}@{sender.split('@')[-1] if '@' in sender else 'outreach.local'}>"
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = to_email
msg["Subject"] = subject
msg["Message-ID"] = msg_id
msg.attach(MIMEText(body, "plain"))
loop = asyncio.get_running_loop()
@@ -78,7 +149,13 @@ class BacklinkOutreachSender:
logger.error(f"Unexpected error sending to {to_email}: {e}")
return False
return await loop.run_in_executor(None, _send)
success = await loop.run_in_executor(None, _send)
return SendEmailResult(
success=success,
effective_sender_email=sender,
message_id=msg_id if success else "",
failure_reasons=[] if success else ["smtp_send_failed"],
)
def personalize(self, template: str, variables: dict) -> str:
"""Replace {placeholder} variables in a template string."""

View File

@@ -4,10 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import asyncio
import re
import time
import requests
import httpx
from bs4 import BeautifulSoup
import csv
@@ -22,9 +23,6 @@ from services.backlink_outreach_models import (
)
from services.backlink_outreach_storage import BacklinkOutreachStorageService
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
@dataclass
class SearchResult:
url: str
@@ -55,51 +53,67 @@ class BacklinkOutreachService:
f"{normalized} + 'Submit article'",
]
def search_for_urls(self, query: str, timeout_seconds: int = 12, retries: int = 2) -> List[SearchResult]:
encoded_query = requests.utils.quote(query)
async def search_for_urls(
self,
query: str,
timeout_seconds: int = 12,
retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> List[SearchResult]:
"""Search DuckDuckGo HTML using a non-blocking HTTP client."""
encoded_query = quote(query)
url = f"https://duckduckgo.com/html/?q={encoded_query}"
headers = {"User-Agent": "Mozilla/5.0 ALwrityBacklinkBot/1.0"}
for attempt in range(retries + 1):
try:
response = requests.get(url, headers=headers, timeout=timeout_seconds)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
rows: List[SearchResult] = []
for result in soup.select("div.result")[:10]:
anchor = result.select_one("a.result__a")
snippet = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
if not anchor or not anchor.get("href"):
continue
rows.append(
SearchResult(
url=anchor.get("href"),
title=anchor.get_text(strip=True),
snippet=snippet.get_text(" ", strip=True) if snippet else "",
async def _request(active_client: httpx.AsyncClient) -> List[SearchResult]:
for attempt in range(retries + 1):
try:
response = await active_client.get(url, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
rows: List[SearchResult] = []
for result in soup.select("div.result")[:10]:
anchor = result.select_one("a.result__a")
snippet = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
if not anchor or not anchor.get("href"):
continue
rows.append(
SearchResult(
url=anchor.get("href"),
title=anchor.get_text(strip=True),
snippet=snippet.get_text(" ", strip=True) if snippet else "",
)
)
)
return rows
except Exception:
if attempt == retries:
return []
time.sleep(0.6 * (attempt + 1))
return []
return rows
except (httpx.HTTPError, httpx.TimeoutException):
if attempt == retries:
return []
await asyncio.sleep(0.6 * (attempt + 1))
return []
def discover_opportunities(self, keyword: str, max_results: int = 10) -> Dict[str, Any]:
if client is not None:
return await _request(client)
timeout = httpx.Timeout(timeout_seconds)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as owned_client:
return await _request(owned_client)
async def discover_opportunities_async(self, keyword: str, max_results: int = 10) -> Dict[str, Any]:
queries = self.generate_guest_post_queries(keyword)[:4]
dedup: Dict[str, SearchResult] = {}
for query in queries:
for result in self.search_for_urls(query):
normalized_url = self._normalize_url(result.url)
if not normalized_url or normalized_url in dedup:
continue
dedup[normalized_url] = result
async with httpx.AsyncClient(timeout=httpx.Timeout(12.0), follow_redirects=True) as client:
for query in queries:
for result in await self.search_for_urls(query, client=client):
normalized_url = self._normalize_url(result.url)
if not normalized_url or normalized_url in dedup:
continue
dedup[normalized_url] = result
if len(dedup) >= max_results:
break
if len(dedup) >= max_results:
break
if len(dedup) >= max_results:
break
time.sleep(0.4)
await asyncio.sleep(0.4)
opportunities: List[OpportunityRecord] = []
for normalized_url, row in dedup.items():
@@ -118,6 +132,10 @@ class BacklinkOutreachService:
return {"keyword": keyword, "queries": queries, "opportunities": opportunities}
def discover_opportunities(self, keyword: str, max_results: int = 10) -> Dict[str, Any]:
"""Synchronous compatibility wrapper for non-async callers."""
return asyncio.run(self.discover_opportunities_async(keyword, max_results))
def _normalize_url(self, url: str) -> str:
u = (url or "").strip()
if not u:
@@ -144,32 +162,76 @@ class BacklinkOutreachService:
def _get_storage(self) -> BacklinkOutreachStorageService:
return BacklinkOutreachStorageService()
CONSENT_REQUIRED_REGIONS = {"eu", "eea", "uk", "ca"}
MANUAL_REVIEW_REGIONS = {"unknown", "br", "cn", "jp", "kr"}
LOW_CONFIDENCE_REGION_SOURCES = {"tld_inference", "domain_tld", "inferred", "unknown"}
VALID_LEGAL_BASES = {"legitimate_interest", "consent", "contract"}
VALID_CONSENT_STATUSES = {"explicit", "implied", "not_required", "unknown"}
@staticmethod
def _has_one_click_unsubscribe(payload: PolicyValidationRequest) -> bool:
one_click = payload.one_click_unsubscribe
if not one_click or not one_click.enabled:
return False
return bool(one_click.mailto or (one_click.header_value or "").strip())
def validate_send_policy(self, payload: PolicyValidationRequest) -> PolicyValidationResponse:
reasons: List[str] = []
storage = self._get_storage()
legal_basis = payload.legal_basis.strip().lower()
recipient_region = payload.recipient_region.strip().lower()
region_source = payload.recipient_region_source.strip().lower()
consent_status = payload.consent_status.strip().lower()
discovery_source = payload.contact_discovery_source.strip()
sender = payload.sender_identity
if payload.workspace_id.startswith("new-") and not payload.approved_by_human:
reasons.append("human_review_required_for_new_workspace")
if payload.legal_basis.lower() not in {"legitimate_interest", "consent", "contract"}:
reasons.append("invalid_legal_basis")
if payload.recipient_region.lower() in {"eu", "eea"} and payload.legal_basis.lower() != "consent":
reasons.append("region_requires_explicit_consent")
if not legal_basis:
reasons.append("legal_basis_required")
elif legal_basis not in self.VALID_LEGAL_BASES:
reasons.append("invalid_legal_basis_recorded")
if not discovery_source:
reasons.append("contact_discovery_source_required")
if consent_status not in self.VALID_CONSENT_STATUSES:
reasons.append("invalid_consent_status")
if len(payload.sender_identity.strip()) < 3:
reasons.append("sender_identity_required")
has_unsubscribe = bool(payload.unsubscribe_url) or self._has_one_click_unsubscribe(payload)
if not has_unsubscribe:
reasons.append("unsubscribe_url_or_one_click_unsubscribe_required")
if not sender:
reasons.append("complete_sender_identity_required")
else:
sender_email = str(sender.email).strip()
if not sender.name.strip():
reasons.append("sender_name_required")
if not sender_email:
reasons.append("sender_email_required")
elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sender_email):
reasons.append("sender_email_invalid")
if not sender.organization.strip():
reasons.append("sender_organization_required")
if not sender.physical_mailing_address.strip():
reasons.append("sender_physical_mailing_address_required")
if payload.sender_email and sender_email.lower() != str(payload.sender_email).lower():
reasons.append("sender_identity_email_mismatch")
if recipient_region in self.CONSENT_REQUIRED_REGIONS:
if legal_basis != "consent" or consent_status != "explicit":
reasons.append("region_requires_recorded_explicit_consent")
elif recipient_region in self.MANUAL_REVIEW_REGIONS and not payload.approved_by_human:
reasons.append("manual_review_required_for_recipient_region")
if region_source in self.LOW_CONFIDENCE_REGION_SOURCES and not payload.approved_by_human:
reasons.append("manual_review_required_for_tld_or_unknown_region_source")
if storage.is_suppressed(str(payload.recipient_email), payload.recipient_domain, user_id=payload.user_id):
reasons.append("recipient_suppressed")
if storage.check_idempotency(payload.idempotency_key, user_id=payload.user_id):
reasons.append("duplicate_idempotency_key")
user_count = storage.get_user_send_count(payload.user_id)
domain_count = storage.get_domain_send_count(payload.recipient_domain, user_id=payload.user_id)
if user_count >= DEFAULT_USER_DAILY_CAP:
reasons.append("user_daily_cap_exceeded")
if domain_count >= DEFAULT_DOMAIN_DAILY_CAP:
reasons.append("domain_daily_cap_exceeded")
allowed = len(reasons) == 0
final_status = "approved" if allowed else "blocked"
@@ -199,15 +261,82 @@ class BacklinkOutreachService:
return "au"
return "unknown"
SMTP_RETRY_POLICY = "manual_retry_with_new_idempotency_key"
@staticmethod
def _decision_parts(attempt: Optional[dict]) -> List[str]:
if not attempt:
return []
reason = attempt.get("decision_reason") or ""
return [part.strip() for part in reason.split(";") if part.strip()]
def response_from_attempt(self, attempt: Optional[dict], duplicate: bool = False) -> SendOutreachResponse:
if not attempt:
return SendOutreachResponse(
attempt_id="",
status="duplicate",
policy_allowed=False,
policy_reasons=["duplicate_idempotency_key"],
duplicate=True,
)
status = attempt.get("status", "failed")
parts = self._decision_parts(attempt)
retry_policy = next((part.split("=", 1)[1] for part in parts if part.startswith("retry_policy=")), None)
reasons = [part for part in parts if not part.startswith("retry_policy=")]
if not retry_policy and ("smtp_send_failed" in reasons or "lead_has_no_email" in reasons):
retry_policy = self.SMTP_RETRY_POLICY
policy_allowed = status in {"queued", "approved", "sent", "failed"} and not any(
reason.startswith("human_review_required")
or reason in {
"invalid_legal_basis",
"region_requires_explicit_consent",
"sender_identity_required",
"recipient_suppressed",
"user_daily_cap_exceeded",
"domain_daily_cap_exceeded",
}
for reason in reasons
)
if status == "blocked":
policy_allowed = False
return SendOutreachResponse(
attempt_id=attempt.get("attempt_id", ""),
status=status,
policy_allowed=policy_allowed,
policy_reasons=reasons,
duplicate=duplicate,
retry_policy=retry_policy,
)
def send_outreach(self, request: SendOutreachRequest) -> SendOutreachResponse:
storage = self._get_storage()
lead = storage.get_lead(request.lead_id, user_id=request.user_id)
if not lead:
return SendOutreachResponse(attempt_id="", status="failed", policy_allowed=False, policy_reasons=["lead_not_found"])
reservation = storage.reserve_attempt_idempotency(
lead_id=request.lead_id,
campaign_id=request.campaign_id,
idempotency_key=request.idempotency_key,
sender_email=request.sender_email,
subject=request.subject,
body=request.body,
user_id=request.user_id,
)
if not reservation.get("reserved"):
return self.response_from_attempt(reservation.get("attempt"), duplicate=True)
attempt = reservation.get("attempt") or {}
attempt_id = attempt.get("attempt_id", "")
domain = lead.get("domain", request.sender_email.split("@")[-1] if "@" in request.sender_email else "unknown")
recipient_region = self._infer_region(domain)
legal_basis = "consent" if recipient_region == "eu" else "legitimate_interest"
recipient_region = (request.recipient_region or "unknown").strip().lower()
if recipient_region == "unknown":
recipient_region = self._infer_region(domain)
region_source = "tld_inference" if recipient_region != "unknown" else request.recipient_region_source
else:
region_source = request.recipient_region_source
policy_req = PolicyValidationRequest(
user_id=request.user_id,
@@ -216,31 +345,32 @@ class BacklinkOutreachService:
recipient_email=lead.get("email", ""),
recipient_domain=domain,
recipient_region=recipient_region,
legal_basis=legal_basis,
approved_by_human=False,
unsubscribe_url=None,
sender_identity=request.sender_email,
recipient_region_source=region_source,
legal_basis=request.legal_basis,
contact_discovery_source=request.contact_discovery_source,
consent_status=request.consent_status,
approved_by_human=request.approved_by_human,
unsubscribe_url=request.unsubscribe_url,
one_click_unsubscribe=request.one_click_unsubscribe,
sender_identity=request.sender_identity,
sender_email=request.sender_email,
idempotency_key=request.idempotency_key,
)
policy = self.validate_send_policy(policy_req)
attempt = storage.add_attempt(
lead_id=request.lead_id,
campaign_id=request.campaign_id,
idempotency_key=request.idempotency_key,
sender_email=request.sender_email,
subject=request.subject,
body=request.body,
status="approved" if policy.allowed else "blocked",
updated_attempt = storage.update_attempt_status(
attempt_id,
"approved" if policy.allowed else "blocked",
decision_reason="; ".join(policy.reasons) if policy.reasons else None,
user_id=request.user_id,
)
) or attempt
return SendOutreachResponse(
attempt_id=attempt.get("attempt_id", ""),
status=attempt.get("status", "failed"),
attempt_id=updated_attempt.get("attempt_id", attempt_id),
status=updated_attempt.get("status", "failed"),
policy_allowed=policy.allowed,
policy_reasons=policy.reasons,
effective_sender_email=request.sender_email,
)
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:
@@ -323,11 +453,23 @@ class BacklinkOutreachService:
writer.writerows([{k: self._sanitize_csv_value(v) for k, v in row.items()}])
return output.getvalue()
async def deep_discover(self, keyword: str, max_results: int = 15) -> Dict[str, Any]:
async def deep_discover(
self,
keyword: str,
max_results: int = 15,
user_id: Optional[str] = None,
scrape_timeout_seconds: float = 15.0,
scrape_max_concurrency: int = 5,
) -> Dict[str, Any]:
"""Enhanced discovery using Exa neural search + DuckDuckGo with full-page scraping."""
from services.backlink_outreach_scraper import BacklinkOutreachScraper
scraper = BacklinkOutreachScraper(user_id=self._user_id if hasattr(self, '_user_id') else None)
return await scraper.deep_discover(keyword, max_results)
scraper = BacklinkOutreachScraper(user_id=user_id)
return await scraper.deep_discover(
keyword,
max_results,
scrape_timeout_seconds=scrape_timeout_seconds,
scrape_max_concurrency=scrape_max_concurrency,
)
def get_migration_coverage(self) -> Dict[str, Any]:
implemented = [

View File

@@ -6,6 +6,9 @@ from datetime import datetime, date
from uuid import uuid4
from typing import List, Optional
from sqlalchemy import text as sql_text, func as sa_func
from sqlalchemy.exc import IntegrityError
LEAD_VALID_STATUSES = frozenset({"discovered", "contacted", "replied", "placed", "bounced", "unsubscribed"})
from services.database import get_session_for_user
from models.backlink_outreach_models import (
@@ -16,6 +19,14 @@ from models.backlink_outreach_models import (
)
class BacklinkCampaignNotFoundError(RuntimeError):
"""Raised when a backlink campaign is missing or not owned by the user."""
DEFAULT_USER_DAILY_CAP = 100
DEFAULT_DOMAIN_DAILY_CAP = 20
class BacklinkOutreachStorageService:
_NEW_LEAD_COLUMNS = [
"url", "page_title", "snippet", "confidence_score", "discovery_source", "notes"
@@ -120,6 +131,14 @@ class BacklinkOutreachStorageService:
# -- Lead CRUD --
def _campaign_belongs_to_user(self, db, campaign_id: str, user_id: str) -> bool:
return (
db.query(BacklinkCampaign)
.filter(BacklinkCampaign.id == campaign_id, BacklinkCampaign.user_id == user_id)
.first()
is not None
)
def add_lead(
self,
campaign_id: str,
@@ -138,6 +157,17 @@ class BacklinkOutreachStorageService:
if not db:
raise RuntimeError("Database session unavailable")
try:
if not self._campaign_belongs_to_user(db, campaign_id, user_id):
raise BacklinkCampaignNotFoundError("Campaign not found")
existing = (
db.query(BacklinkLead)
.filter(BacklinkLead.campaign_id == campaign_id, BacklinkLead.url == url)
.first()
)
if existing:
return self._lead_to_dict(existing)
lead = BacklinkLead(
id=f"bl_{uuid4().hex[:16]}",
campaign_id=campaign_id,
@@ -164,12 +194,25 @@ class BacklinkOutreachStorageService:
if not db:
raise RuntimeError("Database session unavailable")
try:
if not self._campaign_belongs_to_user(db, campaign_id, user_id):
raise BacklinkCampaignNotFoundError("Campaign not found")
existing_urls = {
row[0]
for row in db.query(BacklinkLead.url)
.filter(BacklinkLead.campaign_id == campaign_id)
.all()
}
added = []
for data in leads_data:
url = data.get("url", "")
if url in existing_urls:
continue
lead = BacklinkLead(
id=f"bl_{uuid4().hex[:16]}",
campaign_id=campaign_id,
url=data.get("url", ""),
url=url,
domain=data.get("domain", ""),
page_title=data.get("page_title", ""),
snippet=data.get("snippet", ""),
@@ -182,6 +225,7 @@ class BacklinkOutreachStorageService:
)
db.add(lead)
added.append(lead)
existing_urls.add(url)
db.commit()
return [self._lead_to_dict(l) for l in added]
finally:
@@ -204,8 +248,16 @@ class BacklinkOutreachStorageService:
db.close()
def update_lead_status(
self, lead_id: str, user_id: str, status: str, notes: Optional[str] = None
self,
lead_id: str,
user_id: str,
status: str,
notes: Optional[str] = None,
campaign_id: Optional[str] = None,
) -> Optional[dict]:
if status not in LEAD_VALID_STATUSES:
raise ValueError(f"Invalid status '{status}'. Valid values: {sorted(LEAD_VALID_STATUSES)}")
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
@@ -214,6 +266,18 @@ class BacklinkOutreachStorageService:
lead = db.query(BacklinkLead).filter(BacklinkLead.id == lead_id).first()
if not lead:
return None
campaign = (
db.query(BacklinkCampaign)
.filter(BacklinkCampaign.id == lead.campaign_id, BacklinkCampaign.user_id == user_id)
.first()
)
if not campaign:
raise PermissionError("Lead does not belong to the current user")
if campaign_id and lead.campaign_id != campaign_id:
return None
lead.status = status
if notes is not None:
lead.notes = notes
@@ -222,6 +286,44 @@ class BacklinkOutreachStorageService:
finally:
db.close()
def get_lead_access_issues(
self, lead_ids: List[str], user_id: str, campaign_id: Optional[str] = None
) -> dict:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return {"missing": list(dict.fromkeys(lead_ids)), "unauthorized": []}
try:
unique_lead_ids = list(dict.fromkeys(lead_ids))
access_rows = self._get_lead_access_rows(db, unique_lead_ids)
missing: List[str] = []
unauthorized: List[str] = []
for lid in unique_lead_ids:
access = access_rows.get(lid)
if not access:
missing.append(lid)
elif access["user_id"] != user_id:
unauthorized.append(lid)
elif campaign_id and access["campaign_id"] != campaign_id:
missing.append(lid)
return {"missing": missing, "unauthorized": unauthorized}
finally:
db.close()
def _get_lead_access_rows(self, db, lead_ids: List[str]) -> dict:
if not lead_ids:
return {}
rows = (
db.query(BacklinkLead.id, BacklinkLead.campaign_id, BacklinkCampaign.user_id)
.outerjoin(BacklinkCampaign, BacklinkLead.campaign_id == BacklinkCampaign.id)
.filter(BacklinkLead.id.in_(lead_ids))
.all()
)
return {
row.id: {"campaign_id": row.campaign_id, "user_id": row.user_id}
for row in rows
}
@staticmethod
def _lead_to_dict(lead) -> dict:
return {
@@ -241,6 +343,79 @@ class BacklinkOutreachStorageService:
# -- Outreach Attempt CRUD --
def get_attempt_by_idempotency_key(self, idempotency_key: str, user_id: str = "default") -> Optional[dict]:
"""Return the existing attempt for an idempotency key visible to the user."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
attempt = (
db.query(OutreachAttempt)
.join(BacklinkCampaign, OutreachAttempt.campaign_id == BacklinkCampaign.id)
.filter(
OutreachAttempt.idempotency_key == idempotency_key,
BacklinkCampaign.user_id == user_id,
)
.first()
)
return self._attempt_to_dict(attempt) if attempt else None
finally:
db.close()
def reserve_attempt_idempotency(
self,
lead_id: str,
campaign_id: str,
idempotency_key: str,
sender_email: str = "",
subject: str = "",
body: str = "",
user_id: str = "default",
) -> dict:
"""Atomically reserve an outreach idempotency key by creating the attempt row.
Returns {"reserved": True, "attempt": attempt_dict} for the caller that won
the reservation, or {"reserved": False, "attempt": existing_attempt_or_none}
when the unique key already exists. Duplicate rows are detected by the
database unique constraint so concurrent requests do not both proceed to
policy approval or SMTP delivery.
"""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable")
try:
attempt = OutreachAttempt(
id=f"att_{uuid4().hex[:16]}",
lead_id=lead_id,
campaign_id=campaign_id,
idempotency_key=idempotency_key,
sender_email=sender_email,
subject=subject,
body=body,
status="queued",
created_at=datetime.utcnow(),
)
db.add(attempt)
db.commit()
return {"reserved": True, "attempt": self._attempt_to_dict(attempt)}
except IntegrityError:
db.rollback()
existing = (
db.query(OutreachAttempt)
.join(BacklinkCampaign, OutreachAttempt.campaign_id == BacklinkCampaign.id)
.filter(
OutreachAttempt.idempotency_key == idempotency_key,
BacklinkCampaign.user_id == user_id,
)
.first()
)
return {"reserved": False, "attempt": self._attempt_to_dict(existing) if existing else None}
finally:
db.close()
def add_attempt(
self,
lead_id: str,
@@ -273,6 +448,20 @@ class BacklinkOutreachStorageService:
db.add(attempt)
db.commit()
return self._attempt_to_dict(attempt)
except IntegrityError:
db.rollback()
existing = (
db.query(OutreachAttempt)
.join(BacklinkCampaign, OutreachAttempt.campaign_id == BacklinkCampaign.id)
.filter(
OutreachAttempt.idempotency_key == idempotency_key,
BacklinkCampaign.user_id == user_id,
)
.first()
)
if existing:
return self._attempt_to_dict(existing)
raise
finally:
db.close()
@@ -325,6 +514,7 @@ class BacklinkOutreachStorageService:
"decision_reason": attempt.decision_reason,
"sent_at": attempt.sent_at.isoformat() if attempt.sent_at else None,
"created_at": attempt.created_at.isoformat() if attempt.created_at else None,
"message_id": attempt.message_id or "",
}
def find_attempt_by_from_email(self, from_email: str, user_id: str = "default") -> Optional[str]:
@@ -346,6 +536,37 @@ class BacklinkOutreachStorageService:
finally:
db.close()
def update_attempt_message_id(self, attempt_id: str, message_id: str, user_id: str = "default") -> Optional[dict]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
attempt = db.query(OutreachAttempt).filter(OutreachAttempt.id == attempt_id).first()
if not attempt:
return None
attempt.message_id = message_id
db.commit()
return self._attempt_to_dict(attempt)
finally:
db.close()
def find_attempt_by_message_id(self, message_id: str, user_id: str = "default") -> Optional[str]:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return None
try:
clean = message_id.strip()
attempt = (
db.query(OutreachAttempt)
.filter(OutreachAttempt.message_id == clean)
.first()
)
return attempt.id if attempt else None
finally:
db.close()
# -- Outreach Reply CRUD --
def reply_exists(self, from_email: str, subject: str, user_id: str = "default") -> bool:
@@ -678,6 +899,9 @@ class BacklinkOutreachStorageService:
db.add(entry)
db.commit()
return {"idempotency_key": idempotency_key}
except IntegrityError:
db.rollback()
return {"idempotency_key": idempotency_key}
finally:
db.close()
@@ -686,27 +910,6 @@ class BacklinkOutreachStorageService:
def _today(self) -> date:
return date.today()
def increment_user_send_counter(self, user_id: str) -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
row_id = f"scu_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_user (id, user_id, date, count) "
"VALUES (:id, :uid, :dt, 1) "
"ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "uid": user_id, "dt": today})
db.commit()
result = db.query(SendCounterUser.count).filter(
SendCounterUser.user_id == user_id, SendCounterUser.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_user_send_count(self, user_id: str) -> int:
db = get_session_for_user(user_id)
if not db:
@@ -722,28 +925,6 @@ class BacklinkOutreachStorageService:
finally:
db.close()
def increment_domain_send_counter(self, domain: str, user_id: str = "default") -> int:
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return 0
try:
today = self._today()
domain_lower = domain.lower()
row_id = f"scd_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_domain (id, domain, date, count) "
"VALUES (:id, :dom, :dt, 1) "
"ON CONFLICT (domain, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "dom": domain_lower, "dt": today})
db.commit()
result = db.query(SendCounterDomain.count).filter(
SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today
).first()
return result[0] if result else 0
finally:
db.close()
def get_domain_send_count(self, domain: str, user_id: str = "default") -> int:
db = get_session_for_user(user_id)
if not db:
@@ -759,6 +940,73 @@ class BacklinkOutreachStorageService:
finally:
db.close()
def try_increment_user_send_counter(self, user_id: str) -> tuple:
"""Atomically check cap and increment. Returns (within_cap, new_count)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return True, 0
try:
today = self._today()
current = (
db.query(SendCounterUser.count)
.filter(SendCounterUser.user_id == user_id, SendCounterUser.date == today)
.scalar()
) or 0
if current >= DEFAULT_USER_DAILY_CAP:
db.close()
return False, current
row_id = f"scu_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_user (id, user_id, date, count) "
"VALUES (:id, :uid, :dt, 1) "
"ON CONFLICT (user_id, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "uid": user_id, "dt": today})
db.commit()
result = db.query(SendCounterUser.count).filter(
SendCounterUser.user_id == user_id, SendCounterUser.date == today
).first()
return True, result[0] if result else 0
except Exception:
db.rollback()
return True, 0
finally:
db.close()
def try_increment_domain_send_counter(self, domain: str, user_id: str = "default") -> tuple:
"""Atomically check cap and increment. Returns (within_cap, new_count)."""
self._ensure_tables(user_id)
db = get_session_for_user(user_id)
if not db:
return True, 0
try:
today = self._today()
domain_lower = domain.lower()
current = (
db.query(SendCounterDomain.count)
.filter(SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today)
.scalar()
) or 0
if current >= DEFAULT_DOMAIN_DAILY_CAP:
db.close()
return False, current
row_id = f"scd_{uuid4().hex[:16]}"
db.execute(sql_text(
"INSERT INTO backlink_send_counters_domain (id, domain, date, count) "
"VALUES (:id, :dom, :dt, 1) "
"ON CONFLICT (domain, date) DO UPDATE SET count = count + 1"
), {"id": row_id, "dom": domain_lower, "dt": today})
db.commit()
result = db.query(SendCounterDomain.count).filter(
SendCounterDomain.domain == domain_lower, SendCounterDomain.date == today
).first()
return True, result[0] if result else 0
except Exception:
db.rollback()
return True, 0
finally:
db.close()
# -- Audit Log --
def add_audit_log(

View File

@@ -27,6 +27,7 @@ class BlogSEORecommendationApplier:
raise ValueError("user_id is required for subscription checking. Please provide Clerk user ID.")
title = payload.get("title", "Untitled Blog")
introduction = payload.get("introduction") or ""
sections: List[Dict[str, Any]] = payload.get("sections", [])
outline = payload.get("outline", [])
research = payload.get("research", {})
@@ -44,6 +45,7 @@ class BlogSEORecommendationApplier:
prompt = self._build_prompt(
title=title,
introduction=introduction,
sections=sections,
outline=outline,
research=research,
@@ -57,6 +59,7 @@ class BlogSEORecommendationApplier:
"type": "object",
"properties": {
"title": {"type": "string"},
"introduction": {"type": "string"},
"sections": {
"type": "array",
"items": {
@@ -103,6 +106,13 @@ class BlogSEORecommendationApplier:
raw_sections = result.get("sections", []) or []
normalized_sections: List[Dict[str, Any]] = []
# Warn if LLM returned different number of sections (may miss intro/conclusion added as new sections)
if len(raw_sections) != len(sections):
logger.warning(
f"LLM returned {len(raw_sections)} sections but {len(sections)} were sent. "
"Extra sections will be ignored; missing sections fall back to original content."
)
# Build lookup table from updated sections using their identifiers
updated_map: Dict[str, Dict[str, Any]] = {}
for updated in raw_sections:
@@ -180,9 +190,17 @@ class BlogSEORecommendationApplier:
logger.info("SEO recommendations applied successfully")
# Extract updated introduction from LLM response if available
updated_introduction = result.get("introduction") or ""
if updated_introduction and updated_introduction != introduction:
logger.info(f"Introduction updated: {len(updated_introduction)} chars")
elif not updated_introduction:
updated_introduction = introduction # fall back to original
return {
"success": True,
"title": result.get("title", title),
"introduction": updated_introduction,
"sections": normalized_sections,
"applied": applied,
}
@@ -191,6 +209,7 @@ class BlogSEORecommendationApplier:
self,
*,
title: str,
introduction: str,
sections: List[Dict[str, Any]],
outline: List[Dict[str, Any]],
research: Dict[str, Any],
@@ -244,6 +263,9 @@ You are an expert SEO content strategist. Update the blog content to apply the a
Current Title: {title}
Current Introduction:
{introduction if introduction else '(No introduction exists — write a compelling one if the recommendations require it)'}
Primary Keywords (for context): {primary_keywords}
Outline Overview:
@@ -260,10 +282,15 @@ Actionable Recommendations to Apply:
Instructions:
1. Carefully apply the recommendations while preserving factual accuracy and research alignment.
2. Keep section identifiers (IDs) unchanged so the frontend can map updates correctly.
3. Improve clarity, flow, and SEO optimization per the guidance.
4. Return updated sections in the requested JSON format.
5. Provide a short summary of which recommendations were addressed.
2. You MUST return EXACTLY the same number of sections, with EXACTLY the same IDs as provided above. Do NOT add or remove sections.
3. If a recommendation says content is MISSING (e.g. missing introduction or conclusion), incorporate that missing content into the MOST APPROPRIATE existing section:
- Missing introduction → PREPEND introductory content to the FIRST section's existing content.
- Missing conclusion → APPEND concluding content to the LAST section's existing content.
- For other missing content, add it to the section whose heading best matches the recommendation.
4. Additionally, if an introduction is missing or weak, write a compelling introduction in the "introduction" field of your response. If the current introduction is adequate, return it unchanged.
5. Improve clarity, flow, and SEO optimization per the guidance.
6. Return updated sections in the requested JSON format.
7. Provide a short summary of which recommendations were addressed.
"""
return prompt

View File

@@ -47,7 +47,10 @@ class WixAuthService:
'code_verifier': code_verifier,
}
token_url = f'{self.base_url}/oauth2/token'
logger.info(f"Wix token exchange: client_id={self.client_id}, redirect_uri={self.redirect_uri}, code_verifier_prefix={code_verifier[:10]}...")
response = requests.post(token_url, headers=headers, data=data)
if response.status_code != 200:
logger.error(f"Wix token exchange failed: {response.status_code} {response.text}")
response.raise_for_status()
return response.json()

View File

@@ -55,19 +55,20 @@ def get_wix_headers(
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
elif token.startswith('IST.'):
# Wix Headless API key - send as-is, no Bearer
headers['Authorization'] = token
logger.debug("Using Wix API key for authorization (IST. format detected)")
else:
# Count dots - JWT has exactly 2 dots
# Standard JWT has exactly 2 dots separating header.payload.signature
dot_count = token.count('.')
if dot_count == 2 and len(token) < 500:
# Likely OAuth JWT token - use Bearer prefix
if dot_count == 2:
headers['Authorization'] = f'Bearer {token}'
logger.debug(f"Using OAuth Bearer token (JWT format detected)")
logger.debug("Using OAuth Bearer token (JWT format: 2 dots detected)")
else:
# Likely API key - use directly without Bearer prefix
headers['Authorization'] = token
logger.debug(f"Using API key for authorization (non-JWT format detected)")
logger.debug("Using token as-is (non-JWT format detected)")
if client_id:
headers['wix-client-id'] = client_id
@@ -125,8 +126,10 @@ def should_use_api_key(access_token: Optional[str] = None) -> bool:
access_token = str(access_token)
token = access_token.strip()
if token.count('.') != 2 or len(token) > 500:
if token.startswith('OauthNG.JWS.'):
return False
if token.startswith('IST.'):
return True
return False
# Standard JWT has exactly 2 dots
return token.count('.') != 2

View File

@@ -2,20 +2,22 @@ from typing import Any, Dict, List, Optional
import requests
from loguru import logger
from .retry import wix_api_call_with_retry, WixAPIError
class WixBlogService:
"""Service for Wix Blog API operations with retry logic and error handling."""
def __init__(self, base_url: str, client_id: Optional[str]):
self.base_url = base_url
self.client_id = client_id
def headers(self, access_token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Build headers with automatic token type detection."""
h: Dict[str, str] = {
'Content-Type': 'application/json',
}
# Support both OAuth tokens and API keys
# API keys don't use 'Bearer' prefix
# Ensure access_token is a string (defensive check)
if access_token:
# Normalize token to string if needed
if not isinstance(access_token, str):
@@ -28,20 +30,18 @@ class WixBlogService:
token = access_token.strip()
if token:
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
# These should use "Bearer" prefix even though they have more than 2 dots
if token.startswith('OauthNG.JWS.'):
# Wix OAuth token - use Bearer prefix
h['Authorization'] = f'Bearer {token}'
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
elif '.' not in token or len(token) > 500:
# Likely an API key - use directly without Bearer prefix
elif token.startswith('IST.'):
h['Authorization'] = token
logger.debug("Using API key for authorization")
else:
# Standard JWT OAuth token (xxx.yyy.zzz format) - use Bearer prefix
logger.debug("Using Wix API key for authorization (IST. format detected)")
elif token.count('.') == 2:
h['Authorization'] = f'Bearer {token}'
logger.debug("Using OAuth Bearer token for authorization")
logger.debug("Using OAuth Bearer token for authorization (JWT: 2 dots)")
else:
h['Authorization'] = token
logger.debug("Using token as-is for authorization")
if self.client_id:
h['wix-client-id'] = self.client_id
@@ -50,12 +50,12 @@ class WixBlogService:
return h
def create_draft_post(self, access_token: str, payload: Dict[str, Any], extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Create draft post with consolidated logging"""
"""Create draft post with retry logic and consolidated logging."""
from .logger import wix_logger
import json
import traceback as tb
# Build payload summary for logging
# Build payload summary for logging (safe, no sensitive data)
payload_summary = {}
if 'draftPost' in payload:
dp = payload['draftPost']
@@ -66,64 +66,114 @@ class WixBlogService:
}
request_headers = self.headers(access_token, extra_headers)
logger.debug(f"Wix API request headers: {list(request_headers.keys())}")
if 'wix-site-id' in request_headers:
logger.info(f"Wix API call includes wix-site-id: {request_headers['wix-site-id'][:8]}...")
else:
logger.warning("Wix API call MISSING wix-site-id header — this may fail for multi-site tokens")
url = f"{self.base_url}/blog/v3/draft-posts"
try:
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=request_headers, json=payload)
except TypeError as e:
logger.error(f"TypeError during requests.post in create_draft_post: {e}")
logger.error(f"Traceback: {tb.format_exc()}")
logger.error(f"access_token type: {type(access_token)}")
logger.error(f"payload type: {type(payload)}, keys: {list(payload.keys()) if isinstance(payload, dict) else 'N/A'}")
result = wix_api_call_with_retry('POST', url, request_headers, json_payload=payload, max_attempts=3)
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 200, payload_summary, None)
return result
except WixAPIError as e:
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", e.status_code or 500, payload_summary, e.response_body)
logger.error(f"Wix create_draft_post failed after retries: HTTP {e.status_code} - {e.response_body}")
raise
except Exception as e:
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 500, payload_summary, str(e)[:200])
logger.error(f"Unexpected error in create_draft_post: {e}")
raise
# Consolidated error logging
error_body = None
if response.status_code >= 400:
try:
error_body = response.json()
except:
error_body = {'message': response.text[:200]}
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", response.status_code, payload_summary, error_body)
if response.status_code >= 400:
# Only show detailed error info for debugging
if response.status_code == 500:
logger.debug(f" Full error: {json.dumps(error_body, indent=2) if isinstance(error_body, dict) else error_body}")
response.raise_for_status()
return response.json()
def publish_draft(self, access_token: str, draft_post_id: str, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
response = requests.post(f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json()
"""Publish a draft post with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('POST', url, headers, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix publish_draft failed: HTTP {e.status_code} - {e.response_body}")
raise
def list_categories(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
response = requests.get(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json().get('categories', [])
"""List blog categories with retry logic."""
url = f"{self.base_url}/blog/v3/categories"
headers = self.headers(access_token, extra_headers)
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
return result.get('categories', [])
except WixAPIError as e:
logger.error(f"Wix list_categories failed: HTTP {e.status_code}")
raise
def create_category(self, access_token: str, label: str, description: Optional[str] = None, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
def create_category(self, access_token: str, label: str, description: Optional[str] = None,
language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Create a blog category with retry logic."""
url = f"{self.base_url}/blog/v3/categories"
headers = self.headers(access_token, extra_headers)
payload: Dict[str, Any] = {'category': {'label': label}, 'fieldsets': ['URL']}
if description:
payload['category']['description'] = description
if language:
payload['category']['language'] = language
response = requests.post(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers), json=payload)
response.raise_for_status()
return response.json()
try:
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix create_category failed: HTTP {e.status_code}")
raise
def list_tags(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
response = requests.get(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers))
response.raise_for_status()
return response.json().get('tags', [])
"""List blog tags with retry logic."""
url = f"{self.base_url}/blog/v3/tags"
headers = self.headers(access_token, extra_headers)
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
return result.get('tags', [])
except WixAPIError as e:
logger.error(f"Wix list_tags failed: HTTP {e.status_code}")
raise
def create_tag(self, access_token: str, label: str, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
def create_tag(self, access_token: str, label: str, language: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Create a blog tag with retry logic."""
url = f"{self.base_url}/blog/v3/tags"
headers = self.headers(access_token, extra_headers)
payload: Dict[str, Any] = {'label': label, 'fieldsets': ['URL']}
if language:
payload['language'] = language
response = requests.post(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers), json=payload)
response.raise_for_status()
return response.json()
try:
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix create_tag failed: HTTP {e.status_code}")
raise
def get_draft_post(self, access_token: str, draft_post_id: str,
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Get a draft post by ID with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('GET', url, headers, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix get_draft_post failed: HTTP {e.status_code}")
raise
def update_draft_post(self, access_token: str, draft_post_id: str, payload: Dict[str, Any],
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Update a draft post with retry logic."""
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
headers = self.headers(access_token, extra_headers)
try:
return wix_api_call_with_retry('PUT', url, headers, json_payload=payload, max_attempts=3)
except WixAPIError as e:
logger.error(f"Wix update_draft_post failed: HTTP {e.status_code}")
raise

View File

@@ -5,6 +5,7 @@ Handles blog post creation, validation, and publishing to Wix.
"""
import json
import os
import re
import uuid
import requests
@@ -193,6 +194,7 @@ def create_blog_post(
tag_ids: List[str] = None,
publish: bool = True,
seo_metadata: Dict[str, Any] = None,
site_id: str = None,
import_image_func = None,
lookup_categories_func = None,
lookup_tags_func = None,
@@ -220,111 +222,50 @@ def create_blog_post(
Returns:
Created blog post information
"""
if not member_id:
raise ValueError("memberId is required for third-party apps creating blog posts")
# ===== PRE-FLIGHT VALIDATION =====
errors = []
# Ensure access_token is a string (handle cases where it might be int, dict, or other type)
# Use normalize_token_string to handle various token formats (dict with accessToken.value, etc.)
if not member_id:
errors.append("memberId is required for third-party apps creating blog posts")
title_clean = str(title).strip() if title else ""
if not title_clean:
errors.append("Title is required")
elif len(title_clean) > 200:
errors.append(f"Title is too long ({len(title_clean)} chars, max 200)")
# Ensure access_token is a string
normalized_token = normalize_token_string(access_token)
if not normalized_token:
raise ValueError("access_token is required and must be a valid string or token object")
access_token = normalized_token.strip()
if not access_token:
raise ValueError("access_token cannot be empty")
errors.append("access_token is required and must be a valid string or token object")
else:
access_token = normalized_token.strip()
if not access_token:
errors.append("access_token cannot be empty")
# BACK TO BASICS MODE: Try simplest possible structure FIRST
# Since posting worked before Ricos/SEO, let's test with absolute minimum
BACK_TO_BASICS_MODE = False # Disabled: full Ricos conversion now produces valid output
content_clean = str(content).strip() if content else ""
if not content_clean:
logger.warning("Content was empty, using default text")
content = "This is a post from ALwrity."
elif len(content_clean) > 100000:
errors.append(f"Content is too long ({len(content_clean)} chars, max 100,000)")
if errors:
raise ValueError(f"Wix publish validation failed: {'; '.join(errors)}")
wix_logger.reset()
wix_logger.log_operation_start("Blog Post Creation", title=title[:50] if title else None, member_id=member_id[:20] if member_id else None)
if BACK_TO_BASICS_MODE:
logger.info("🔧 Wix: BACK TO BASICS MODE - Testing minimal structure")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Create absolute minimal Ricos structure
minimal_ricos = {
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {
'text': (content[:500] if content else "This is a post from ALwrity.").strip(),
'decorations': []
}
}]
}]
}
# Extract wix-site-id from token if possible
extra_headers = {}
try:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
except Exception:
pass
# Build minimal payload
minimal_blog_data = {
'draftPost': {
'title': str(title).strip() if title else "Untitled",
'memberId': str(member_id).strip(),
'richContent': minimal_ricos
},
'publish': False,
'fieldsets': ['URL']
}
try:
from .blog import WixBlogService
blog_service_test = WixBlogService('https://www.wixapis.com', None)
result = blog_service_test.create_draft_post(access_token, minimal_blog_data, extra_headers if extra_headers else None)
logger.success("✅✅✅ Wix: BACK TO BASICS SUCCEEDED! Issue is with Ricos/SEO structure")
wix_logger.log_operation_result("Back to Basics Test", True, result)
return result
except Exception as e:
logger.error(f"❌ Wix: BACK TO BASICS FAILED - {str(e)[:100]}")
logger.error(" ⚠️ Issue is NOT with Ricos/SEO - likely permissions/token")
wix_logger.add_error(f"Back to Basics: {str(e)[:100]}")
# Import auth utilities for proper token handling
from .auth_utils import get_wix_headers
# Headers for blog post creation (use user's OAuth token)
headers = get_wix_headers(access_token)
# Build valid Ricos rich content
# Ensure content is not empty
if not content or not content.strip():
content = "This is a post from ALwrity."
logger.warning("⚠️ Content was empty, using default text")
# Quick token/permission check (only log if issues found)
has_blog_scope = None
meta_site_id = None
try:
from .utils import decode_wix_token
import json
from .utils import decode_wix_token, extract_meta_from_token
token_data = decode_wix_token(access_token)
if 'scope' in token_data:
scopes = token_data.get('scope')
@@ -332,17 +273,9 @@ def create_blog_post(
scope_list = scopes.split(',') if ',' in scopes else [scopes]
has_blog_scope = any('BLOG' in s.upper() for s in scope_list)
if not has_blog_scope:
logger.error("Wix: Token missing BLOG scopes - verify OAuth app permissions")
if 'data' in token_data:
data = token_data.get('data')
if isinstance(data, str):
try:
data = json.loads(data)
except:
pass
if isinstance(data, dict) and 'instance' in data:
instance = data.get('instance', {})
meta_site_id = instance.get('metaSiteId')
logger.error("Wix: Token missing BLOG scopes - verify OAuth app permissions")
meta_info = extract_meta_from_token(access_token)
meta_site_id = meta_info.get('metaSiteId')
except Exception:
pass
@@ -352,13 +285,12 @@ def create_blog_post(
import requests
test_response = requests.get(f"{base_url}/blog/v3/categories", headers=test_headers, timeout=5)
if test_response.status_code == 403:
logger.error("Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
logger.error("Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
elif test_response.status_code == 401:
logger.error("Wix: Unauthorized - token may be expired")
logger.error("Wix: Unauthorized - token may be expired")
except Exception:
pass
# Safely get token length (access_token is already validated as string above)
token_length = len(access_token) if access_token else 0
wix_logger.log_token_info(token_length, has_blog_scope, meta_site_id)
@@ -470,19 +402,20 @@ def create_blog_post(
if cover_image_url and import_image_func:
try:
media_id = import_image_func(access_token, cover_image_url, f'Cover: {title}')
# Ensure media_id is a string and not None
if media_id and isinstance(media_id, str):
# import_image_to_wix now returns Optional[str] — None means failure
if media_id and isinstance(media_id, str) and media_id.strip():
blog_data['draftPost']['media'] = {
'wixMedia': {
'image': {'id': str(media_id).strip()}
'image': {'id': media_id.strip()}
},
'displayed': True,
'custom': True
}
logger.info(f"Cover image imported: {media_id[:16]}...")
else:
logger.warning(f"Invalid media_id type or value: {type(media_id)}, skipping media")
logger.warning(f"Cover image import returned no valid media_id (type={type(media_id)}). Continuing without cover image.")
except Exception as e:
logger.warning(f"Failed to import cover image: {e}")
logger.warning(f"Cover image import failed (non-fatal): {e}. Continuing without cover image.")
# Handle categories - can be either IDs (list of strings) or names (for lookup)
category_ids_to_use = None
@@ -558,34 +491,33 @@ def create_blog_post(
logger.debug("No SEO metadata provided to create_blog_post")
try:
# Extract wix-site-id from token if possible
# Extract wix-site-id from token, parameter, or env var
extra_headers = {}
try:
wix_site_id = site_id or os.getenv('WIX_SITE_ID')
if not wix_site_id:
from .utils import extract_meta_from_token
meta_info = extract_meta_from_token(access_token)
wix_site_id = meta_info.get('metaSiteId')
if wix_site_id:
extra_headers['wix-site-id'] = wix_site_id
logger.info(f"Using wix-site-id: {wix_site_id[:8]}... (source: {'param' if site_id else 'env' if os.getenv('WIX_SITE_ID') else 'token'})")
else:
token_str = str(access_token)
if token_str and token_str.startswith('OauthNG.JWS.'):
import jwt
import json
jwt_part = token_str[12:]
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except:
pass
instance_data = data_payload.get('instance', {})
meta_site_id = instance_data.get('metaSiteId')
if isinstance(meta_site_id, str) and meta_site_id:
extra_headers['wix-site-id'] = meta_site_id
except Exception:
pass
if token_str.startswith('IST.'):
logger.error("❌ IST. API key requires WIX_SITE_ID environment variable or site_id parameter. "
"The token's tenant.id is the account ID, not the site ID. "
"Please set WIX_SITE_ID in your .env file to your Wix site's metaSiteId.")
else:
logger.warning("No wix-site-id found — API calls may fail if token requires it")
except Exception as e:
logger.debug(f"Could not extract wix-site-id from token: {e}")
try:
# Validate payload structure before sending
draft_post = blog_data.get('draftPost', {})
if not isinstance(draft_post, dict):
raise ValueError("draftPost must be a dict object")
# Validate richContent structure
if 'richContent' in draft_post:
rc = draft_post['richContent']
if not isinstance(rc, dict):
@@ -595,8 +527,7 @@ def create_blog_post(
if not isinstance(rc['nodes'], list):
raise ValueError(f"richContent.nodes must be a list, got {type(rc['nodes'])}")
logger.debug(f"✅ richContent validation passed: {len(rc.get('nodes', []))} nodes")
# Validate seoData structure if present
if 'seoData' in draft_post:
seo = draft_post['seoData']
if not isinstance(seo, dict):
@@ -606,46 +537,40 @@ def create_blog_post(
if 'settings' in seo and not isinstance(seo['settings'], dict):
raise ValueError(f"seoData.settings must be a dict, got {type(seo.get('settings'))}")
logger.debug(f"✅ seoData validation passed: {len(seo.get('tags', []))} tags")
# Final validation: Ensure no None values in any nested objects
# Wix API rejects None values and expects proper types
try:
validate_payload_no_none(blog_data, "blog_data")
logger.debug("✅ Payload validation passed: No None values found")
except ValueError as e:
logger.error(f"❌ Payload validation failed: {e}")
raise
# Log payload summary
logger.debug(f"Payload: draftPost keys={list(draft_post.keys())}, "
f"nodes={len(draft_post.get('richContent', {}).get('nodes', []))}, "
f"has_seo={'seoData' in draft_post}")
# Final deep validation: Serialize and deserialize to catch any JSON-serialization issues
try:
import json
json.dumps(blog_data, ensure_ascii=False)
except (TypeError, ValueError) as e:
logger.error(f"❌ Payload JSON serialization failed: {e}")
raise ValueError(f"Payload contains non-serializable data: {e}")
# Clean up None values that Wix API would reject
rc = blog_data['draftPost']['richContent']
for field in ['documentStyle', 'metadata']:
if field in rc and (rc[field] is None or rc[field] == "" or not isinstance(rc[field], dict)):
del rc[field]
logger.info(f"📤 Publishing to Wix: title='{blog_data['draftPost'].get('title', '')}', "
f"nodes={len(rc.get('nodes', []))}")
result = blog_service.create_draft_post(access_token, blog_data, extra_headers or None)
# Log success
draft_post = result.get('draftPost', {})
post_id = draft_post.get('id', 'N/A')
wix_logger.log_operation_result("Create Draft Post", True, result)
logger.success(f"✅ Wix: Blog post created - ID: {post_id}")
return result
except TypeError as e:
import traceback

View File

@@ -5,79 +5,71 @@ from typing import Any, Dict, List
def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
"""
Parse inline markdown formatting (bold, italic, links) into Ricos text nodes.
Parse inline markdown formatting (bold, italic, links, code, strikethrough) into Ricos text nodes.
Returns a list of text nodes with decorations.
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
Handles: **bold**, *italic*, [links](url), `code`, ~strikethrough~, and combinations.
"""
if not text:
return [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {'text': '', 'decorations': []}
}]
nodes = []
# Process text character by character to handle nested/adjacent formatting
# This is more robust than regex for complex cases
i = 0
current_text = ''
current_decorations = []
def flush_text():
nonlocal current_text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': current_text, 'decorations': []}
})
current_text = ''
while i < len(text):
# Check for bold **text** (must come before single * check)
# Bold **text**
if i < len(text) - 1 and text[i:i+2] == '**':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
# Find closing **
flush_text()
end_bold = text.find('**', i + 2)
if end_bold != -1:
bold_text = text[i + 2:end_bold]
# Recursively parse the bold text for nested formatting
bold_nodes = parse_markdown_inline(bold_text)
# Add BOLD decoration to all text nodes within
# Per Wix API: decorations are objects with 'type' field, not strings
for node in bold_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if BOLD decoration already exists
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
if not has_bold:
node_decorations.append({'type': 'BOLD'})
node['textData']['decorations'] = node_decorations
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
decs.append({'type': 'BOLD'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_bold + 2
continue
# Check for link [text](url)
# Strikethrough ~text~
elif text[i] == '~':
flush_text()
end_strike = text.find('~', i + 1)
if end_strike != -1:
strike_text = text[i + 1:end_strike]
strike_nodes = parse_markdown_inline(strike_text)
for node in strike_nodes:
if node['type'] == 'TEXT':
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'STRIKETHROUGH' for d in decs if isinstance(d, dict)):
decs.append({'type': 'STRIKETHROUGH'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_strike + 1
continue
# Link [text](url)
elif text[i] == '[':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find matching ]
flush_text()
link_end = text.find(']', i)
if link_end != -1 and link_end < len(text) - 1 and text[link_end + 1] == '(':
link_text = text[i + 1:link_end]
@@ -85,12 +77,10 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
url_end = text.find(')', url_start)
if url_end != -1:
url = text[url_start:url_end]
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
# Create TEXT node with LINK decoration
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': link_text,
'decorations': [{
@@ -98,7 +88,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
'linkData': {
'link': {
'url': url,
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
'target': 'BLANK'
}
}
}]
@@ -107,33 +97,17 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = url_end + 1
continue
# Check for code `text`
# Inline code `text`
elif text[i] == '`':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing `
flush_text()
code_end = text.find('`', i + 1)
if code_end != -1:
code_text = text[i + 1:code_end]
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
# For inline code, we'll just use plain text for now
# Wix doesn't have a CODE decoration, but we can preserve the text
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': code_text,
'decorations': [] # CODE is not a valid decoration in Wix API
@@ -142,39 +116,21 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = code_end + 1
continue
# Check for italic *text* (only if not part of **)
# Italic *text* (must come after ** check)
elif text[i] == '*' and (i == 0 or text[i-1] != '*') and (i == len(text) - 1 or text[i+1] != '*'):
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing * (but not **)
flush_text()
italic_end = text.find('*', i + 1)
if italic_end != -1:
# Make sure it's not part of **
if italic_end == len(text) - 1 or text[italic_end + 1] != '*':
italic_text = text[i + 1:italic_end]
italic_nodes = parse_markdown_inline(italic_text)
# Add ITALIC decoration
# Per Wix API: decorations are objects with 'type' field
for node in italic_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if ITALIC decoration already exists
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
if not has_italic:
node_decorations.append({'type': 'ITALIC'})
node['textData']['decorations'] = node_decorations
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'ITALIC' for d in decs if isinstance(d, dict)):
decs.append({'type': 'ITALIC'})
node['textData']['decorations'] = decs
nodes.append(node)
i = italic_end + 1
continue
@@ -183,58 +139,116 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
current_text += text[i]
i += 1
# Add any remaining text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
flush_text()
# If no nodes created, return single plain text node
if not nodes:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': text,
'decorations': []
}
'nodes': [],
'textData': {'text': text, 'decorations': []}
})
return nodes
def _make_code_block_node(code_text: str, language: str = '') -> Dict[str, Any]:
"""Create a Ricos CODE_BLOCK node."""
lines = code_text.split('\n')
text_nodes = []
for line in lines:
text_nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': line, 'decorations': []}
})
return {
'id': str(uuid.uuid4()),
'type': 'CODE_BLOCK',
'nodes': text_nodes,
'codeBlockData': {
'language': language or 'text',
'textWrap': True
}
}
def _make_horizontal_rule_node() -> Dict[str, Any]:
"""Create a Ricos DIVIDER node."""
return {
'id': str(uuid.uuid4()),
'type': 'DIVIDER',
'nodes': [],
'dividerData': {
'type': 'LINE',
'lineStyle': {
'width': 'LARGE',
'alignment': 'CENTER'
}
}
}
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
"""
Convert markdown content into valid Ricos JSON format.
Supports headings, paragraphs, lists, bold, italic, links, and images.
Supports:
- Headings (# to ######)
- Paragraphs with inline formatting
- Unordered lists (-, *)
- Ordered lists (1., 2.)
- Blockquotes (>)
- Code blocks (```language ... ```)
- Inline images (![alt](url))
- Horizontal rules (---, ***, ___)
"""
if not content:
content = "This is a post from ALwrity."
nodes = []
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
line = lines[i]
stripped = line.strip()
if not line:
if not stripped:
i += 1
continue
node_id = str(uuid.uuid4())
# Check for headings
if line.startswith('#'):
level = len(line) - len(line.lstrip('#'))
heading_text = line.lstrip('# ').strip()
# Code blocks (```language ... ```)
if stripped.startswith('```'):
language = stripped[3:].strip() or ''
code_lines = []
i += 1
while i < len(lines):
if lines[i].strip() == '```':
i += 1
break
code_lines.append(lines[i])
i += 1
code_text = '\n'.join(code_lines)
if code_text.strip():
nodes.append(_make_code_block_node(code_text, language))
continue
# Horizontal rules
if re.match(r'^(---+|\*\*\*|___+)$', stripped):
nodes.append(_make_horizontal_rule_node())
i += 1
continue
# Headings
if stripped.startswith('#'):
level = len(stripped) - len(stripped.lstrip('#'))
heading_text = stripped.lstrip('# ').strip()
text_nodes = parse_markdown_inline(heading_text)
nodes.append({
'id': node_id,
@@ -243,42 +257,38 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'headingData': {'level': min(level, 6)}
})
i += 1
continue
# Check for blockquotes
elif line.startswith('>'):
quote_text = line.lstrip('> ').strip()
# Continue reading consecutive blockquote lines
quote_lines = [quote_text]
# Blockquotes
if stripped.startswith('>'):
quote_lines = [stripped.lstrip('> ').strip()]
i += 1
while i < len(lines) and lines[i].strip().startswith('>'):
quote_lines.append(lines[i].strip().lstrip('> ').strip())
i += 1
quote_content = ' '.join(quote_lines)
text_nodes = parse_markdown_inline(quote_content)
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
# Wix API: omit empty data objects, don't include them as {}
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
blockquote_node = {
nodes.append({
'id': node_id,
'type': 'BLOCKQUOTE',
'nodes': [paragraph_node],
}
nodes.append(blockquote_node)
})
continue
# Check for unordered lists (handle both '- ' and '* ' markers)
elif (line.startswith('- ') or line.startswith('* ') or
(line.startswith('-') and len(line) > 1 and line[1] != '-') or
(line.startswith('*') and len(line) > 1 and line[1] != '*')):
# Unordered lists
if (stripped.startswith('- ') or stripped.startswith('* ') or
(stripped.startswith('-') and len(stripped) > 1 and stripped[1] != '-') or
(stripped.startswith('*') and len(stripped) > 1 and stripped[1] != '*')):
list_items = []
list_marker = '- ' if line.startswith('-') else '* '
# Process list items
list_marker = '- ' if stripped.startswith('-') else '* '
while i < len(lines):
current_line = lines[i].strip()
# Check if this is a list item
is_list_item = (current_line.startswith('- ') or current_line.startswith('* ') or
(current_line.startswith('-') and len(current_line) > 1 and current_line[1] != '-') or
(current_line.startswith('*') and len(current_line) > 1 and current_line[1] != '*'))
@@ -286,12 +296,9 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
if not is_list_item:
break
# Extract item text (handle both '- ' and '-item' formats)
if current_line.startswith('- ') or current_line.startswith('* '):
item_text = current_line[2:].strip()
elif current_line.startswith('-'):
item_text = current_line[1:].strip()
elif current_line.startswith('*'):
elif current_line.startswith('-') or current_line.startswith('*'):
item_text = current_line[1:].strip()
else:
item_text = current_line
@@ -302,52 +309,41 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
# Check for nested items (indented with 2+ spaces)
while i < len(lines):
next_line = lines[i]
# Must be indented and be a list marker
if next_line.startswith(' ') and (next_line.strip().startswith('- ') or
next_line.strip().startswith('* ') or
(next_line.strip().startswith('-') and len(next_line.strip()) > 1) or
(next_line.strip().startswith('*') and len(next_line.strip()) > 1)):
if (next_line.startswith(' ') and
(next_line.strip().startswith('- ') or next_line.strip().startswith('* '))):
nested_text = next_line.strip()
if nested_text.startswith('- ') or nested_text.startswith('* '):
nested_text = nested_text[2:].strip()
elif nested_text.startswith('-'):
nested_text = nested_text[1:].strip()
elif nested_text.startswith('*'):
elif nested_text.startswith('-') or nested_text.startswith('*'):
nested_text = nested_text[1:].strip()
list_items.append(nested_text)
i += 1
else:
break
# Build list items with proper formatting
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
bulleted_list_node = {
nodes.append({
'id': node_id,
'type': 'BULLETED_LIST',
'nodes': list_node_items,
}
nodes.append(bulleted_list_node)
})
continue
# Check for ordered lists
elif re.match(r'^\d+\.\s+', line):
# Ordered lists
if re.match(r'^\d+\.\s+', stripped):
list_items = []
while i < len(lines) and re.match(r'^\d+\.\s+', lines[i].strip()):
item_text = re.sub(r'^\d+\.\s+', '', lines[i].strip())
@@ -359,35 +355,30 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
list_items.append(nested_text)
i += 1
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
ordered_list_node = {
nodes.append({
'id': node_id,
'type': 'ORDERED_LIST',
'nodes': list_node_items,
}
nodes.append(ordered_list_node)
})
continue
# Check for images
elif line.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
# Images
if stripped.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', stripped)
if img_match:
alt_text = img_match.group(1)
img_url = img_match.group(2)
@@ -407,62 +398,52 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
}
})
i += 1
continue
# Regular paragraph
else:
# Collect consecutive non-empty lines as paragraph content
para_lines = [line]
para_lines = [stripped]
i += 1
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
# Stop if next line is a special markdown element
if (next_line.startswith('#') or
next_line.startswith('- ') or
next_line.startswith('* ') or
next_line.startswith('>') or
next_line.startswith('![') or
next_line.startswith('```') or
re.match(r'^(---+|\*\*\*|___+)$', next_line) or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_line)
i += 1
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
# Stop if next line is a special markdown element
if (next_line.startswith('#') or
next_line.startswith('- ') or
next_line.startswith('* ') or
next_line.startswith('>') or
next_line.startswith('![') or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_line)
i += 1
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
# Only add paragraph if there are text nodes
if text_nodes:
paragraph_node = {
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
nodes.append(paragraph_node)
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
if text_nodes:
nodes.append({
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
})
# Ensure at least one node exists
# Wix API: omit empty data objects, don't include them as {}
if not nodes:
fallback_paragraph = {
nodes.append({
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': content[:500] if content else "This is a post from ALwrity.",
'decorations': []
}
}],
}
nodes.append(fallback_paragraph)
})
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
return {
'nodes': nodes
}
return {'nodes': nodes}

View File

@@ -1,17 +1,33 @@
from typing import Any, Dict
from typing import Any, Dict, Optional
import requests
from loguru import logger
from .retry import wix_api_call_with_retry, WixAPIError
class WixMediaService:
"""Service for Wix Media Manager operations with retry logic and error handling."""
def __init__(self, base_url: str):
self.base_url = base_url
def import_image(self, access_token: str, image_url: str, display_name: str) -> Dict[str, Any]:
def import_image(self, access_token: str, image_url: str, display_name: str) -> Optional[Dict[str, Any]]:
"""
Import external image to Wix Media Manager.
Official endpoint: https://www.wixapis.com/site-media/v1/files/import
Reference: https://dev.wix.com/docs/rest/assets/media/media-manager/files/import-file
Args:
access_token: Valid access token
image_url: URL of the image to import
display_name: Display name for the image
Returns:
Media result dict with 'file' key, or None on failure
Raises:
WixAPIError: On non-retryable failure or after retries exhausted
"""
headers = {
'Authorization': f'Bearer {access_token}',
@@ -22,10 +38,54 @@ class WixMediaService:
'mediaType': 'IMAGE',
'displayName': display_name,
}
# Correct endpoint per Wix API documentation
endpoint = f"{self.base_url}/site-media/v1/files/import"
response = requests.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
return response.json()
try:
result = wix_api_call_with_retry(
'POST', endpoint, headers, json_payload=payload, max_attempts=2
)
if result and 'file' in result and 'id' in result['file']:
logger.info(f"Image imported successfully: {result['file']['id'][:16]}...")
return result
else:
logger.warning(f"Image import returned unexpected structure: {list(result.keys()) if isinstance(result, dict) else type(result)}")
return None
except WixAPIError as e:
if e.status_code == 403:
logger.error(f"Image import forbidden (403): OAuth app may lack MEDIA.SITE_MEDIA_FILES_IMPORT scope")
elif e.status_code == 400:
logger.error(f"Image import bad request (400): {e.response_body}")
elif e.status_code == 404:
logger.error(f"Image import endpoint not found (404) — Wix Media API may not be available for this site")
else:
logger.error(f"Image import failed after retries: HTTP {e.status_code} - {e.response_body}")
raise
except Exception as e:
logger.error(f"Unexpected error importing image: {e}")
raise
def get_image_url(self, access_token: str, media_id: str) -> Optional[str]:
"""
Get public URL for a Wix media item.
Args:
access_token: Valid access token
media_id: Wix media ID
Returns:
Public URL string, or None
"""
url = f"{self.base_url}/site-media/v1/files/{media_id}"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
try:
result = wix_api_call_with_retry('GET', url, headers, max_attempts=2)
if result and 'file' in result:
return result['file'].get('url')
return None
except Exception as e:
logger.warning(f"Failed to get image URL for {media_id}: {e}")
return None

View File

@@ -0,0 +1,168 @@
"""
Retry utilities for Wix API calls with exponential backoff.
Production-grade retry logic that respects Wix rate limits and handles
transient failures gracefully.
"""
import time
import random
from typing import Callable, TypeVar, Optional
from loguru import logger
T = TypeVar('T')
class WixAPIError(Exception):
"""Custom exception for Wix API errors with status code context."""
def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
def is_retryable(self) -> bool:
"""Determine if this error is retryable based on status code."""
if self.status_code is None:
return True # Network errors are retryable
# 429 = rate limit, 502/503/504 = gateway errors, 500 = internal server error (sometimes transient)
return self.status_code in (429, 500, 502, 503, 504)
def is_rate_limit(self) -> bool:
"""Check if this is a rate limit error."""
return self.status_code == 429
def with_retry(
fn: Callable[[], T],
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
operation_name: str = "Wix API call"
) -> T:
"""
Execute a function with exponential backoff retry logic.
Args:
fn: Function to execute (should make the API call)
max_attempts: Maximum number of attempts (default: 3)
base_delay: Initial delay in seconds (default: 1.0)
max_delay: Maximum delay in seconds (default: 30.0)
retryable_exceptions: Tuple of exception types to retry on
operation_name: Name for logging
Returns:
Result of fn()
Raises:
WixAPIError: If all retries are exhausted
Exception: If a non-retryable exception occurs
"""
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return fn()
except WixAPIError as e:
last_exception = e
if attempt >= max_attempts:
break
if not e.is_retryable():
logger.warning(f"{operation_name}: non-retryable error (HTTP {e.status_code}), failing fast")
raise
# Calculate delay with exponential backoff and jitter
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# Add jitter (±25%) to prevent thundering herd
jitter = delay * 0.25
actual_delay = delay + random.uniform(-jitter, jitter)
actual_delay = max(0.1, actual_delay) # Minimum 100ms delay
if e.is_rate_limit():
# For rate limits, use a longer base delay
actual_delay = max(actual_delay, 2.0)
logger.warning(f"{operation_name}: rate limited (429), waiting {actual_delay:.1f}s before retry {attempt + 1}/{max_attempts}")
else:
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed (HTTP {e.status_code}), waiting {actual_delay:.1f}s before retry")
time.sleep(actual_delay)
except retryable_exceptions as e:
last_exception = e
if attempt >= max_attempts:
break
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
jitter = delay * 0.25
actual_delay = delay + random.uniform(-jitter, jitter)
actual_delay = max(0.1, actual_delay)
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed ({type(e).__name__}), waiting {actual_delay:.1f}s before retry")
time.sleep(actual_delay)
# All retries exhausted
if last_exception:
if isinstance(last_exception, WixAPIError):
raise last_exception
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts: {last_exception}")
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts")
def wix_api_call_with_retry(
method: str,
url: str,
headers: dict,
json_payload: Optional[dict] = None,
max_attempts: int = 3
) -> dict:
"""
Convenience wrapper for making Wix API calls with retry logic.
Args:
method: HTTP method ('GET', 'POST', etc.)
url: Full API URL
headers: Request headers
json_payload: Optional JSON payload for POST/PUT
max_attempts: Maximum retry attempts
Returns:
Parsed JSON response
Raises:
WixAPIError: On failure after retries
"""
import requests
def _call():
if method.upper() == 'GET':
resp = requests.get(url, headers=headers, timeout=30)
elif method.upper() == 'POST':
resp = requests.post(url, headers=headers, json=json_payload, timeout=30)
elif method.upper() == 'PUT':
resp = requests.put(url, headers=headers, json=json_payload, timeout=30)
elif method.upper() == 'DELETE':
resp = requests.delete(url, headers=headers, timeout=30)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
if resp.status_code >= 400:
body = None
try:
body = resp.text[:500]
except:
body = str(resp.content)[:500]
raise WixAPIError(
f"Wix API {method} {url} failed: HTTP {resp.status_code}",
status_code=resp.status_code,
response_body=body
)
return resp.json()
return with_retry(
_call,
max_attempts=max_attempts,
operation_name=f"Wix {method} {url.split('/')[-1]}"
)

View File

@@ -85,24 +85,45 @@ def decode_wix_token(access_token: str) -> Dict[str, Any]:
if token_str.startswith('OauthNG.JWS.'):
jwt_part = token_str[12:]
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
if token_str.startswith('IST.'):
jwt_part = token_str[4:]
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
return jwt.decode(token_str, options={"verify_signature": False, "verify_aud": False})
def _extract_data_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except Exception:
data_payload = {}
return data_payload if isinstance(data_payload, dict) else {}
def extract_meta_from_token(access_token: str) -> Dict[str, Optional[str]]:
try:
payload = decode_wix_token(access_token)
data_payload = payload.get('data', {})
if isinstance(data_payload, str):
try:
data_payload = json.loads(data_payload)
except Exception:
pass
instance = (data_payload or {}).get('instance', {})
return {
data_payload = _extract_data_payload(payload)
instance = (data_payload or {}).get('instance', {}) or {}
result = {
'siteMemberId': instance.get('siteMemberId'),
'metaSiteId': instance.get('metaSiteId'),
'permissions': instance.get('permissions'),
}
# Only fall back to tenant.id for OAuth tokens (not IST. API keys)
# IST. tokens have tenant.id = account_id, which is NOT the site metaSiteId
token_str = str(access_token)
if not result.get('metaSiteId') and not token_str.startswith('IST.'):
tenant = data_payload.get('tenant', {}) or {}
tenant_id = tenant.get('id')
if tenant_id:
result['metaSiteId'] = tenant_id
if not result.get('metaSiteId'):
meta_site_id = payload.get('metaSiteId') or payload.get('site_id')
if meta_site_id:
result['metaSiteId'] = meta_site_id
return result
except Exception:
return {'siteMemberId': None, 'metaSiteId': None, 'permissions': None}

View File

@@ -86,185 +86,6 @@ class StrategyArchitectAgent(SIFBaseAgent):
logger.error(f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}")
return []
class ContentGuardianAgent(SIFBaseAgent):
"""Agent for preventing cannibalization and ensuring content originality."""
CANNIBALIZATION_THRESHOLD = 0.85 # Similarity threshold for cannibalization warning
ORIGINALITY_THRESHOLD = 0.75 # Minimum originality score
def __init__(self, intelligence_service: TxtaiIntelligenceService, sif_service: Any = None):
super().__init__(intelligence_service)
self.sif_service = sif_service
async def check_cannibalization(self, new_draft: str) -> Dict[str, Any]:
"""Check if a new draft competes semantically with existing pages."""
self._log_agent_operation("Checking for semantic cannibalization", draft_length=len(new_draft))
try:
if not self.intelligence.is_initialized():
logger.error(f"[{self.__class__.__name__}] Intelligence service not initialized")
return {"warning": False, "error": "Service not initialized"}
if not new_draft or len(new_draft.strip()) < 50:
logger.warning(f"[{self.__class__.__name__}] Draft too short for meaningful analysis")
return {"warning": False, "reason": "Draft too short"}
results = await self.intelligence.search(new_draft, limit=1)
if not results:
logger.info(f"[{self.__class__.__name__}] No similar content found - draft is unique")
return {"warning": False, "uniqueness_score": 1.0}
top_result = results[0]
similarity_score = top_result.get('score', 0.0)
logger.debug(f"[{self.__class__.__name__}] Top similarity score: {similarity_score:.4f}")
if similarity_score > self.CANNIBALIZATION_THRESHOLD:
warning_data = {
"warning": True,
"similar_to": top_result.get('id', 'unknown'),
"score": similarity_score,
"threshold": self.CANNIBALIZATION_THRESHOLD,
"recommendation": "Consider revising the draft to target a different angle or merge with existing content"
}
logger.warning(f"[{self.__class__.__name__}] Cannibalization detected: {warning_data}")
return warning_data
logger.info(f"[{self.__class__.__name__}] No cannibalization detected. Draft is sufficiently unique.")
return {"warning": False, "uniqueness_score": 1.0 - similarity_score}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Failed to check cannibalization: {e}")
logger.error(f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}")
return {"warning": False, "error": str(e)}
async def verify_originality(self, text: str, competitor_index: Any) -> Dict[str, Any]:
"""Verify originality against competitor content index."""
self._log_agent_operation("Verifying originality against competitors", text_length=len(text))
try:
if not text or len(text.strip()) < 50:
logger.warning(f"[{self.__class__.__name__}] Text too short for meaningful originality check")
return {"originality_score": 0.0, "reason": "Text too short"}
# STUB: Implement cross-index search against competitor content
# This would search the text against a competitor-specific index
logger.info(f"[{self.__class__.__name__}] Originality verification stub completed")
return {
"originality_score": 0.95, # Placeholder
"confidence": 0.8,
"method": "semantic_comparison",
"notes": "Competitor index integration pending"
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Failed to verify originality: {e}")
logger.error(f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}")
return {"originality_score": 0.0, "error": str(e)}
async def style_enforcer(self, text: str, style_guidelines: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Tool: Ensures content adheres to brand voice and style guidelines.
"""
self._log_agent_operation("Enforcing style guidelines", text_length=len(text))
try:
if not text:
return {"compliance_score": 0.0, "issues": ["No text provided"]}
# 1. Fetch Style Guidelines from SIF if not provided
if not style_guidelines and self.sif_service:
try:
# Search for website analysis to get brand voice/style
# We assume the most relevant 'website_analysis' doc contains the guidelines
results = await self.intelligence.search("website analysis brand voice style", limit=1)
if results:
import json
res = results[0]
metadata_str = res.get('object')
metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else (metadata_str or res)
if metadata.get('type') == 'website_analysis':
report = metadata.get('full_report', {})
style_guidelines = {
"tone": report.get('brand_analysis', {}).get('brand_voice', 'neutral'),
"style_patterns": report.get('style_patterns', {}),
"writing_style": report.get('writing_style', {})
}
logger.info(f"[{self.__class__.__name__}] Retrieved style guidelines from SIF: {style_guidelines.get('tone')}")
except Exception as e:
logger.warning(f"[{self.__class__.__name__}] Failed to retrieve style guidelines from SIF: {e}")
issues = []
score = 1.0
# Basic Heuristic Checks (Placeholder for LLM-based style analysis)
# 1. Tone Check (e.g., formal vs casual)
# If guidelines specify 'formal', check for contractions
tone = style_guidelines.get('tone', '').lower() if style_guidelines else ''
if 'formal' in tone or 'professional' in tone:
contractions = ["can't", "won't", "don't", "it's"]
found_contractions = [c for c in contractions if c in text.lower()]
if found_contractions:
issues.append(f"Found contractions in formal text: {', '.join(found_contractions[:3])}...")
score -= 0.1
# 2. Length/Sentence Structure (simple metric)
sentences = text.split('.')
avg_len = sum(len(s.split()) for s in sentences if s) / max(1, len(sentences))
if avg_len > 25:
issues.append("Average sentence length is too high (>25 words). Consider shortening.")
score -= 0.1
return {
"compliance_score": max(0.0, score),
"issues": issues,
"is_compliant": score > 0.8,
"guidelines_source": "sif_index" if not style_guidelines and self.sif_service else "provided"
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Style enforcement failed: {e}")
return {"error": str(e)}
async def safety_filter(self, text: str) -> Dict[str, Any]:
"""
Tool: Flags potentially harmful, offensive, or sensitive content.
"""
self._log_agent_operation("Running safety filter", text_length=len(text))
try:
# Basic Keyword Blocklist (Placeholder for LLM/Safety Model)
# In production, this should call a dedicated safety API (e.g., OpenAI Moderation, Llama Guard)
unsafe_keywords = [
"hate", "kill", "murder", "attack", "destroy", # Violent
"scam", "fraud", "steal", # Illegal
"explicit", "adult" # NSFW
]
found_flags = []
text_lower = text.lower()
for keyword in unsafe_keywords:
if f" {keyword} " in text_lower: # Simple word boundary check
found_flags.append(keyword)
is_safe = len(found_flags) == 0
return {
"is_safe": is_safe,
"flags": found_flags,
"safety_score": 1.0 if is_safe else 0.0,
"action": "approve" if is_safe else "flag_for_review"
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Safety filter failed: {e}")
return {"error": str(e)}
class LinkGraphAgent(SIFBaseAgent):
"""
Agent for internal link suggestions, graph management, and authority analysis.

View File

@@ -40,6 +40,7 @@ from .specialized_agents import (
)
from .trend_surfer_agent import TrendSurferAgent
from .content_gap_radar_agent import ContentGapRadarAgent
# Agent Orchestrator
from .agent_orchestrator import (
@@ -67,6 +68,7 @@ __all__ = [
'SEOOptimizationAgent',
'SocialAmplificationAgent',
'TrendSurferAgent',
'ContentGapRadarAgent',
'ALwrityAgentOrchestrator',
'orchestration_service'
]

View File

@@ -230,7 +230,7 @@ class ALwrityAgentOrchestrator:
# Content Guardian Agent
if enabled_by_key.get("content_guardian", True):
try:
from services.intelligence.sif_agents import ContentGuardianAgent
from services.intelligence.agents.specialized.content_guardian import ContentGuardianAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
# Initialize intelligence service if not already available
@@ -248,6 +248,19 @@ class ALwrityAgentOrchestrator:
except Exception as e:
logger.error(f"Failed to initialize ContentGuardianAgent: {e}")
# Content Gap Radar Agent
if enabled_by_key.get("content_gap_radar", True):
try:
from services.intelligence.agents import ContentGapRadarAgent
from services.intelligence.txtai_service import TxtaiIntelligenceService
intel_service = TxtaiIntelligenceService(self.user_id)
self.content_gap_radar_agent = ContentGapRadarAgent(intel_service, self.user_id)
self.agents['content_gap_radar'] = self.content_gap_radar_agent
initialized_agents.append("Content Gap Radar")
logger.info(f"Initialized ContentGapRadarAgent for user {self.user_id}")
except Exception as e:
logger.error(f"Failed to initialize ContentGapRadarAgent: {e}")
logger.info(f"Created {len(self.agents)} specialized agents for user {self.user_id}")
# Log initialization activity
@@ -449,7 +462,8 @@ class ALwrityAgentOrchestrator:
"competitor": ["Competitor monitoring", "Threat analysis", "Response generation", "Strategy execution"],
"seo": ["SEO auditing", "Issue prioritization", "Auto-fixing", "Strategy generation"],
"social": ["Social monitoring", "Content adaptation", "Engagement optimization", "Distribution management"],
"trend": ["Trend detection", "Opportunity analysis", "Content angle generation"]
"trend": ["Trend detection", "Opportunity analysis", "Content angle generation"],
"content_gap_radar": ["Content gap detection", "SERP opportunity scoring", "Competitor content deep-dive", "ROI-based topic prioritization", "Content brief generation"]
}
# Service class for agent orchestration

View File

@@ -0,0 +1,466 @@
"""
Content Gap Radar Agent
Scores and prioritizes content opportunities by combining SIF semantic gap analysis,
SERP ranking presence (Google CSE), competitor content deep-dive (Exa), and trend
momentum into a single ROI score per topic.
Phase 3 of the Content Gap Radar feature.
"""
import traceback
from typing import List, Dict, Any, Optional
from loguru import logger
from services.intelligence.agents.specialized import SIFBaseAgent
from services.intelligence.agents.specialized.strategy_architect import StrategyArchitectAgent
from services.intelligence.agents.trend_surfer_agent import TrendSurferAgent
from services.intelligence.agents.core_agent_framework import TaskProposal
from services.intelligence.txtai_service import TxtaiIntelligenceService
from services.seo_tools.serp_gap_service import SerpGapService
from services.seo_tools.competitor_content_service import CompetitorContentService
class ContentGapRadarAgent(SIFBaseAgent):
"""
Agent that scores and prioritizes content opportunities by combining
SIF semantic gap analysis, SERP ranking presence, Exa competitor content,
and trend momentum into a single ROI score.
"""
def __init__(self, intelligence_service: TxtaiIntelligenceService, user_id: str, **kwargs):
super().__init__(intelligence_service, user_id, agent_type="content_gap_radar", **kwargs)
self.user_id = user_id
self.serp_service = SerpGapService()
self.competitor_content_service = CompetitorContentService()
self.strategy_architect = StrategyArchitectAgent(intelligence_service, user_id)
async def analyze(
self,
competitor_domains: List[str],
competitor_indices: Optional[List[Any]] = None,
topics: Optional[List[str]] = None,
bypass_cache: bool = False,
) -> Dict[str, Any]:
"""
Full content gap radar pipeline.
1. Get topic-level gaps from SIF semantic analysis
2. Get SERP ranking data per topic
3. Get Exa competitor content for top topics
4. Get trend momentum data
5. Score each topic with ROI formula
6. Return prioritized results
Args:
competitor_domains: Known competitor domains
competitor_indices: SIF index positions for competitor docs
topics: Optional explicit topic list (derived from SIF if omitted)
bypass_cache: Force fresh API calls
Returns:
Dict with scored gaps list and summary.
"""
self._log_agent_operation(
"Running content gap radar",
competitor_count=len(competitor_domains),
topics_provided=bool(topics),
)
try:
sif_gaps = []
# Step 1: Derive topics from SIF semantic gaps if not provided
if not topics:
sif_gaps = await self.strategy_architect.find_semantic_gaps(
competitor_indices or []
)
topics = [g["topic"] for g in sif_gaps[:12]]
logger.info(
f"[{self.__class__.__name__}] Derived {len(topics)} topics from SIF gaps"
)
if not topics:
logger.info(f"[{self.__class__.__name__}] No topics to analyze")
return {"gaps": [], "summary": {}}
# If we got sif_gaps externally but topics were provided, fetch SIF data anyway
if not sif_gaps:
try:
sif_gaps = await self.strategy_architect.find_semantic_gaps(
competitor_indices or []
)
except Exception as e:
logger.warning(
f"[{self.__class__.__name__}] SIF gap fetch failed (non-fatal): {e}"
)
sif_gaps = []
# Build lookup maps for cross-referencing
sif_map = {g["topic"]: g for g in sif_gaps}
# Step 2: SERP gap analysis
serp_data = await self.serp_service.analyze_topic_gaps(
topics, competitor_domains, bypass_cache=bypass_cache
)
serp_map = {}
for g in serp_data.get("gaps", []):
serp_map[g["topic"]] = g
# Step 3: Exa deep-dive (top 6 topics — paid API)
exa_data = await self.competitor_content_service.deep_dive(
topics[:6], competitor_domains, bypass_cache=bypass_cache
)
exa_map = {}
for r in exa_data.get("results", []):
exa_map[r["topic"]] = r
# Step 4: Trend momentum data
trend_surfer = TrendSurferAgent(
self.intelligence, self.user_id
)
trend_signals = await trend_surfer.surf_trends()
# Step 5: Score each topic
scored = []
for topic in topics:
scored.append(
self._score_topic(
topic=topic,
sif_map=sif_map,
serp_map=serp_map,
exa_map=exa_map,
trend_signals=trend_signals,
)
)
scored.sort(key=lambda x: x["roi_score"], reverse=True)
# Step 6: Summary
high = [g for g in scored if g["priority"] == "high"]
medium = [g for g in scored if g["priority"] == "medium"]
low = [g for g in scored if g["priority"] == "low"]
logger.info(
f"[{self.__class__.__name__}] Scored {len(scored)} gaps: "
f"{len(high)} high, {len(medium)} medium, {len(low)} low"
)
return {
"gaps": scored,
"summary": {
"total_topics_analyzed": len(topics),
"high_priority": len(high),
"medium_priority": len(medium),
"low_priority": len(low),
},
}
except Exception as e:
logger.error(
f"[{self.__class__.__name__}] Content gap radar failed: {e}"
)
logger.error(
f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}"
)
return {"gaps": [], "summary": {}, "error": str(e)}
async def propose_daily_tasks(self, context: Dict[str, Any]) -> List[TaskProposal]:
"""
Propose high-ROI content tasks from gap radar analysis.
Integrates with Today's Workflow agent committee polling.
"""
proposals = []
onboarding = context.get("onboarding_data", {})
competitor_focus = onboarding.get("competitor_focus", {})
competitor_domains = competitor_focus.get("top_competitor_domains", [])
if not competitor_domains:
logger.info(f"[{self.__class__.__name__}] No competitor domains in context, skipping")
return proposals
try:
result = await self.analyze(
competitor_domains=competitor_domains,
competitor_indices=[],
)
except Exception as e:
logger.error(f"[{self.__class__.__name__}] propose_daily_tasks failed: {e}")
return proposals
gaps = result.get("gaps", [])
scored = [g for g in gaps if g["priority"] in ("high", "medium")]
scored.sort(key=lambda x: x["roi_score"], reverse=True)
for gap in scored[:3]:
pillar_id = self._action_to_pillar(gap["recommended_action"])
action_url = (
"/blog-writer"
if pillar_id == "generate"
else "/seo-dashboard#content-gap-radar"
)
proposals.append(TaskProposal(
title=f"Write about: {gap['topic']}",
description=gap["recommended_action"],
pillar_id=pillar_id,
priority=gap["priority"],
estimated_time=60 if pillar_id == "generate" else 30,
source_agent="ContentGapRadarAgent",
reasoning=(
f"Content gap with {gap['scoring']['gap_size']:.0%} gap size, "
f"{gap['scoring']['volume']:.0%} volume, "
f"{gap['scoring']['trend']:.0%} trend momentum, "
f"ROI {gap['roi_score']:.0%}"
),
action_type="navigate",
action_url=action_url,
context_data={"gap": gap},
))
return proposals
@staticmethod
def _action_to_pillar(recommended_action: str) -> str:
action_lower = recommended_action.lower()
if "optimize" in action_lower:
return "analyze"
return "generate"
def _score_topic(
self,
topic: str,
sif_map: Dict[str, Any],
serp_map: Dict[str, Any],
exa_map: Dict[str, Any],
trend_signals: List[Any],
) -> Dict[str, Any]:
"""Score a single topic with the ROI formula."""
# gap_size: from SIF coverage_delta
sif = sif_map.get(topic, {})
gap_size = sif.get("coverage_delta", 0.5)
# volume: from SERP gap — competitors ranking for this topic
serp = serp_map.get(topic, {})
comp_count = serp.get("competitor_count", 0)
total_domains = serp.get("total_domains_checked", 1)
volume = min(comp_count / max(total_domains, 1), 1.0)
# trend: match topic against TrendSurfer signals
trend_score = self._match_trend_score(topic, trend_signals)
# intent: classify topic commercial value
intent = self._classify_intent(topic)
# competition: Exa content depth as penalty
exa = exa_map.get(topic, {})
content_count = exa.get("total_results", 0)
competition = min(content_count / 10.0, 1.0)
# ROI = (gap_size × volume × trend × intent) × (1 - 0.3 × competition)
base_roi = gap_size * volume * trend_score * intent
roi = base_roi * (1 - 0.3 * competition)
# Priority thresholds
if roi >= 0.6:
priority = "high"
elif roi >= 0.3:
priority = "medium"
else:
priority = "low"
# Recommended action based on scoring profile
action = self._recommend_action(gap_size, competition, intent)
return {
"topic": topic,
"roi_score": round(roi, 3),
"priority": priority,
"recommended_action": action,
"scoring": {
"gap_size": round(gap_size, 3),
"volume": round(volume, 3),
"trend": round(trend_score, 3),
"intent": round(intent, 3),
"competition": round(competition, 3),
},
"sif_gap": sif if sif else None,
"serp_evidence": {
"competitors_found": serp.get("competitors_found", []),
"competitor_count": comp_count,
"domains_with_content": serp.get("domains_with_content", []),
} if serp else None,
"competitor_content": exa if exa else None,
}
def _match_trend_score(self, topic: str, signals: List[Dict[str, Any]]) -> float:
if not signals:
return 0.5
topic_lower = topic.lower()
topic_words = set(topic_lower.split())
best_score = 0.0
for signal in signals:
impact = signal.get("impact_score", 0.5)
text_fields = " ".join(filter(None, [
signal.get("topic", ""),
signal.get("headline", ""),
signal.get("suggested_angle", ""),
]))
text_lower = text_fields.lower()
if topic_lower in text_lower:
best_score = max(best_score, impact)
text_words = set(text_lower.split())
overlap = len(topic_words & text_words)
if overlap > 0:
word_score = (overlap / max(len(topic_words), 1)) * impact
best_score = max(best_score, word_score)
return max(best_score, 0.5)
def _classify_intent(self, topic: str) -> float:
"""
Classify topic intent using LLM with keyword fallback.
Returns intent score 0.0-1.0.
"""
topic_lower = topic.lower()
# Keyword-based heuristics
commercial_words = [
"best", "top", "review", "vs", "comparison", "alternative",
"vs.", "versus", "pricing", "cost", "price", "cheap",
"affordable", "discount", "coupon", "deal", "buy",
]
transactional_words = [
"buy", "purchase", "order", "subscribe", "sign up",
"download", "get started", "free trial", "demo",
]
has_commercial = any(w in topic_lower for w in commercial_words)
has_transactional = any(w in topic_lower for w in transactional_words)
if has_transactional:
return 0.9
if has_commercial:
return 0.7
return 0.4 # Informational default
def _recommend_action(
self, gap_size: float, competition: float, intent: float
) -> str:
"""Generate a recommended action based on scoring profile."""
if gap_size > 0.7 and competition < 0.3:
return "Create comprehensive pillar page — large gap, low competition"
elif gap_size > 0.5 and intent > 0.6:
return "Create high-conversion content — significant gap, strong intent"
elif competition > 0.7:
return "Create differentiated content — high competition requires unique angle"
elif gap_size < 0.3:
return "Optimize existing content — incremental gap, update current pages"
else:
return "Create targeted blog post — moderate opportunity"
async def generate_content_brief(
self,
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,
) -> Dict[str, Any]:
"""
Generate a structured content brief from a gap item.
Uses LLM to produce title options, outline sections, target keywords,
and a writing angle. Falls back to template-based generation on LLM failure.
"""
gap_size = (scoring or {}).get("gap_size", 0.5)
volume = (scoring or {}).get("volume", 0.5)
trend = (scoring or {}).get("trend", 0.5)
intent = (scoring or {}).get("intent", 0.5)
competition = (scoring or {}).get("competition", 0.5)
word_count = 800 if competition > 0.7 else 1200 if gap_size > 0.5 else 600
serp_context = ""
if serp_evidence and serp_evidence.get("competitors_found"):
snippets = [
f"- {c.get('title','')}: {c.get('snippet','')[:100]}"
for c in serp_evidence["competitors_found"][:3]
]
serp_context = "Competitor content already ranking:\n" + "\n".join(snippets)
sif_context = ""
if sif_gap:
sif_context = (
f"SIF coverage delta: {sif_gap.get('coverage_delta', 0):.2%}, "
f"confidence: {sif_gap.get('confidence', 0):.2%}"
)
prompt = f"""You are a senior content strategist. Create a detailed content brief for the topic below.
TOPIC: {topic}
RECOMMENDED ACTION: {recommended_action}
{serp_context}
{sif_context}
Scoring profile:
- Gap size: {gap_size:.0%}
- Search volume: {volume:.0%}
- Trend momentum: {trend:.0%}
- Intent score: {intent:.0%}
- Competition level: {competition:.0%}
- Target word count: {word_count}
Return a JSON object with these exact keys:
{{
"titles": ["Title option 1", "Title option 2", "Title option 3"],
"outline": [
{{"heading": "Section heading", "key_points": ["point 1", "point 2", "point 3"]}}
],
"keywords": ["keyword1", "keyword2", "keyword3", "keyword4", "keyword5"],
"angle": "A single paragraph describing the strategic writing angle",
"word_count": {word_count}
}}
Generate 4-6 outline sections. Only return valid JSON, no other text."""
try:
response = await self._generate_llm_response(prompt)
import json as _json
start = response.find("{")
end = response.rfind("}") + 1
if start >= 0 and end > start:
brief = _json.loads(response[start:end])
else:
raise ValueError("No JSON found in LLM response")
except Exception as e:
logger.warning(
f"[{self.__class__.__name__}] LLM brief generation failed, using template: {e}"
)
brief = {
"titles": [
f"The Ultimate Guide to {topic}",
f"{topic}: Strategies That Actually Work",
f"Why {topic} Matters More Than Ever",
],
"outline": [
{"heading": f"Introduction to {topic}", "key_points": ["Context and importance", "What this guide covers"]},
{"heading": "Why This Matters", "key_points": ["Current landscape", "Key challenges and opportunities"]},
{"heading": "Key Strategies", "key_points": ["Strategy 1 with examples", "Strategy 2 with implementation tips", "Strategy 3 for advanced practitioners"]},
{"heading": "Common Pitfalls to Avoid", "key_points": ["Mistake 1 and how to avoid it", "Mistake 2 and how to avoid it"]},
{"heading": "Measuring Success", "key_points": ["Key metrics to track", "Tools and methods for measurement"]},
{"heading": "Conclusion & Next Steps", "key_points": ["Summary of key takeaways", "Actionable next steps"]},
],
"keywords": [topic] + [topic.split()[-1]] if len(topic.split()) > 1 else [topic, "guide", "strategy"],
"angle": f"Create comprehensive, actionable content about {topic} that fills the gap identified in competitor analysis. Focus on providing unique insights and practical implementation guidance.",
"word_count": word_count,
}
return {
"topic": topic,
"recommended_action": recommended_action,
"brief": brief,
"scoring": scoring,
}

View File

@@ -144,25 +144,25 @@ class CompetitorResponseAgent(BaseALwrityAgent):
proposals.append(TaskProposal(
title="Review Competitor Content",
description=f"SIF found {competitor_count} competitor pages. Review for gap opportunities.",
pillar_id="create",
pillar_id="analyze",
priority="high",
estimated_time=45,
source_agent="CompetitorResponseAgent",
reasoning="SIF-detected competitor activity presents content gap opportunities.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url="/seo-dashboard"
))
else:
proposals.append(TaskProposal(
title="Research Competitor Topics",
description="Search for competitor content in your niche to identify coverage gaps.",
pillar_id="create",
pillar_id="analyze",
priority="medium",
estimated_time=30,
source_agent="CompetitorResponseAgent",
reasoning="Understanding competitor positioning improves content strategy.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url="/seo-dashboard"
))
return proposals

View File

@@ -1,6 +1,11 @@
"""
Content Guardian Agent implementation.
Content Guardian Agent — ALwrity's committee watchdog.
Audits committee proposals, evaluates agent behaviour, flags coverage gaps,
and alerts the user when agents need correction.
"""
import json
import traceback
import asyncio
from typing import List, Dict, Any, Optional
from datetime import datetime
from loguru import logger
@@ -8,59 +13,414 @@ from .base import SIFBaseAgent, TXTAI_AVAILABLE, Agent
from services.intelligence.agents.core_agent_framework import TaskProposal
from services.intelligence.txtai_service import TxtaiIntelligenceService
class ContentGuardianAgent(SIFBaseAgent):
"""Agent for monitoring brand consistency and quality."""
def __init__(self, intelligence_service: TxtaiIntelligenceService, user_id: str, **kwargs):
# Pass kwargs to superclass to handle 'task' and other framework arguments
super().__init__(intelligence_service, user_id, agent_type="content_guardian", **kwargs)
# ── known committee agents for critique ──────────────────────────
KNOWN_AGENTS = {
"ContentStrategyAgent": {"label": "Content Strategy", "short": "Strategy", "pillar_focus": "plan"},
"StrategyArchitectAgent": {"label": "Strategy Architect", "short": "Architect", "pillar_focus": "plan"},
"SEOOptimizationAgent": {"label": "SEO Optimization", "short": "SEO", "pillar_focus": "analyze"},
"SocialAmplificationAgent":{"label": "Social Amplification","short": "Social", "pillar_focus": "engage"},
"CompetitorResponseAgent": {"label": "Competitor Response", "short": "Competitor", "pillar_focus": "analyze"},
"ContentGapRadarAgent": {"label": "Content Gap Radar", "short": "Gap Radar", "pillar_focus": "generate"},
}
PILLAR_IDS = {"plan", "generate", "publish", "analyze", "engage", "remarket"}
COMMITTEE_CYCLE_WINDOW_DAYS = 30
class ContentGuardianAgent(SIFBaseAgent):
"""Committee watchdog — audits proposals, critiques agents, flags faults, alerts users."""
CANNIBALIZATION_THRESHOLD = 0.85
ORIGINALITY_THRESHOLD = 0.75
def __init__(self, intelligence_service: TxtaiIntelligenceService, user_id: str, sif_service: Any = None, **kwargs):
super().__init__(intelligence_service, user_id, agent_type="content_guardian", **kwargs)
self.sif_service = sif_service
# ── existing utilities ────────────────────────────────────────
async def _create_txtai_agent(self):
"""Create a specialized txtai Agent for content review."""
if not TXTAI_AVAILABLE or Agent is None:
return None
try:
_llm_for_agent = getattr(self.llm, "llm", self.llm)
return Agent(
tools=[
{
"name": "brand_voice_checker",
"description": "Checks content against brand voice guidelines",
"target": self._check_brand_voice
}
],
llm=_llm_for_agent,
max_iterations=3
)
tools=[{"name": "brand_voice_checker", "description": "Checks content against brand voice guidelines", "target": self._check_brand_voice}],
llm=_llm_for_agent, max_iterations=3)
except Exception as e:
logger.error(f"Failed to create txtai agent for ContentGuardian: {e}")
raise e
logger.error(f"Failed to create txtai agent for ContentGuardian: {e}"); raise e
def _check_brand_voice(self, content: str) -> Dict[str, Any]:
"""Tool to check brand voice consistency."""
# This would use semantic search to compare against brand guidelines
return {
"consistent": True,
"score": 0.95,
"notes": "Content aligns with professional/authoritative tone."
}
return {"consistent": True, "score": 0.95, "notes": "Content aligns with professional/authoritative tone."}
async def propose_daily_tasks(self, context: Dict[str, Any]) -> List[TaskProposal]:
"""Propose quality assurance tasks."""
proposals = []
# 1. Content Freshness Audit
proposals.append(TaskProposal(
title="Audit Old Content",
description="Review top performing posts from >6 months ago for updates.",
pillar_id="create",
priority="low",
estimated_time=30,
source_agent="ContentGuardianAgent",
reasoning="Maintains content relevance and authority.",
action_type="navigate",
action_url="/content-planning-dashboard"
))
return proposals
return [TaskProposal(title="Audit Old Content", description="Review top performing posts from >6 months ago for updates.", pillar_id="create", priority="low", estimated_time=30, source_agent="ContentGuardianAgent", reasoning="Maintains content relevance and authority.", action_type="navigate", action_url="/content-planning-dashboard")]
async def perform_site_audit(self, website_url: str) -> Dict[str, Any]:
self._log_agent_operation("Performing site audit", website_url=website_url)
try:
results = await self.intelligence.search(f"website content analysis {website_url}", limit=10)
audit: Dict[str, Any] = {"website_url": website_url, "audit_timestamp": datetime.utcnow().isoformat(), "total_pages_crawled": len(results), "content_quality": None, "brand_voice_consistency": None, "safety_issues": None, "cannibalization_issues": None}
if not results: return audit
quality_scores, style_scores, safety_flags = [], [], []
for result in results:
text = result.get("text", "") or result.get("id", "")
if len(text) < 50: continue
quality = await self.assess_content_quality({"description": text, "title": website_url}); quality_scores.append(quality.get("score", 0.0))
style = await self.style_enforcer(text); style_scores.append(style.get("compliance_score", 0.0))
safety = await self.safety_filter(text)
if not safety.get("is_safe", True): safety_flags.append(safety.get("flags", []))
audit["content_quality"] = {"score": round(sum(quality_scores)/max(len(quality_scores),1),4), "pages_analyzed": len(quality_scores)}
audit["brand_voice_consistency"] = {"compliance_score": round(sum(style_scores)/max(len(style_scores),1),4), "pages_checked": len(style_scores)}
audit["safety_issues"] = {"has_issues": len(safety_flags)>0, "flagged_pages": len(safety_flags)}
audit["cannibalization_issues"] = await self.check_cannibalization(website_url)
return audit
except Exception as e: logger.error(f"[{self.__class__.__name__}] Site audit failed: {e}"); return {"website_url": website_url, "error": str(e), "audit_timestamp": datetime.utcnow().isoformat()}
async def assess_content_quality(self, website_data: Dict[str, Any]) -> Dict[str, Any]:
self._log_agent_operation("Assessing content quality")
try:
text = website_data.get('description','') or website_data.get('title','')
if not text: return {"score":0.5,"reason":"No content to analyze"}
style = await self.style_enforcer(text); safety = await self.safety_filter(text)
base = style.get('compliance_score',0.8)
if safety.get('action')=='flag_for_review': base*=0.5
return {"score":base,"style_analysis":style,"safety_analysis":safety,"analyzed_text_length":len(text)}
except Exception as e: return {"score":0.0,"error":str(e)}
async def check_cannibalization(self, new_draft: str) -> Dict[str, Any]:
self._log_agent_operation("Checking for semantic cannibalization", draft_length=len(new_draft))
try:
if not await self._ensure_intelligence_ready(): return {"warning":False,"error":"Service not initialized"}
if not new_draft or len(new_draft.strip())<50: return {"warning":False,"reason":"Draft too short"}
results = await self.intelligence.search(new_draft, limit=1)
if not results: return {"warning":False,"uniqueness_score":1.0}
score = results[0].get('score',0.0)
if score > self.CANNIBALIZATION_THRESHOLD: return {"warning":True,"similar_to":results[0].get('id','unknown'),"score":score,"threshold":self.CANNIBALIZATION_THRESHOLD,"recommendation":"Consider revising the draft to target a different angle or merge with existing content"}
return {"warning":False,"uniqueness_score":1.0-score}
except Exception as e: return {"warning":False,"error":str(e)}
async def verify_originality(self, text: str, competitor_index: Any) -> Dict[str, Any]:
"""(unchanged — kept for backward compat)"""
self._log_agent_operation("Verifying originality against competitors", text_length=len(text))
try:
if not text or len(text.strip())<50: return {"originality_score":0.0,"reason":"Text too short"}
query = text.strip(); competitor_results = []; method="user_index_competitor_filter"
if competitor_index is not None and hasattr(competitor_index,"search"):
method="competitor_index_search"; raw=competitor_index.search(query,limit=5)
if asyncio.iscoroutine(raw): raw=await raw
competitor_results=raw or []
else:
raw=await self.intelligence.search(query,limit=10)
for r in raw or []:
m_raw=r.get("object"); m=m_raw if isinstance(m_raw,dict) else {}
if not m and isinstance(m_raw,str):
try: m=json.loads(m_raw)
except Exception: m={}
if "competitor" in str(m.get("type","")).lower() or "competitor" in str(m.get("source","")).lower():
competitor_results.append(r)
if not competitor_results: return {"originality_score":1.0,"confidence":0.6,"method":method,"notes":"No competitor overlap detected"}
top=max(competitor_results,key=lambda i:float(i.get("score",0.0))); s=max(0.0,min(1.0,float(top.get("score",0.0))))
os_=max(0.0,round(1.0-s,4)); c=round(min(1.0,0.55+(min(len(competitor_results),5)*0.07)),3)
return {"originality_score":os_,"confidence":c,"method":method,"warning":os_<self.ORIGINALITY_THRESHOLD,"threshold":self.ORIGINALITY_THRESHOLD,"top_competitor_match":{"id":top.get("id"),"score":round(s,4)},"matches_evaluated":len(competitor_results)}
except Exception as e: return {"originality_score":0.0,"error":str(e)}
async def style_enforcer(self, text: str, style_guidelines: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
self._log_agent_operation("Enforcing style guidelines", text_length=len(text))
try:
if not text: return {"compliance_score":0.0,"issues":["No text provided"]}
if not style_guidelines and self.sif_service:
try:
r=await self.intelligence.search("website analysis brand voice style",limit=1)
if r:
m_raw=r[0].get('object'); m=json.loads(m_raw) if isinstance(m_raw,str) else (m_raw or r[0])
if m.get('type')=='website_analysis':
rep=m.get('full_report',{}); style_guidelines={"tone":rep.get('brand_analysis',{}).get('brand_voice','neutral'),"style_patterns":rep.get('style_patterns',{}),"writing_style":rep.get('writing_style',{})}
except Exception: pass
issues=[]; score=1.0
tone=(style_guidelines or {}).get('tone','').lower()
if 'formal' in tone or 'professional' in tone:
found=[c for c in ["can't","won't","don't","it's"] if c in text.lower()]
if found: issues.append(f"Found contractions in formal text: {', '.join(found[:3])}..."); score-=0.1
sentences=text.split('.'); avg=sum(len(s.split()) for s in sentences if s)/max(1,len(sentences))
if avg>25: issues.append("Average sentence length is too high (>25 words). Consider shortening."); score-=0.1
return {"compliance_score":max(0.0,score),"issues":issues,"is_compliant":score>0.8,"guidelines_source":"sif_index" if not style_guidelines and self.sif_service else "provided"}
except Exception as e: return {"error":str(e)}
async def safety_filter(self, text: str) -> Dict[str, Any]:
self._log_agent_operation("Running safety filter", text_length=len(text))
try:
kw=["hate","kill","murder","attack","destroy","scam","fraud","steal","explicit","adult"]
found=[k for k in kw if f" {k} " in text.lower()]
ok=len(found)==0
return {"is_safe":ok,"flags":found,"safety_score":1.0 if ok else 0.0,"action":"approve" if ok else "flag_for_review"}
except Exception as e: return {"error":str(e)}
# ═══════════════════════════════════════════════════════════════
# COMMITTEE WATCHDOG — the core audit entry point
# ═══════════════════════════════════════════════════════════════
async def audit_committee(self, proposals: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Audits a batch of committee proposals and returns a structured report.
proposals: list of dicts with at minimum:
agent, title, pillar_id, priority, reasoning, accepted, valid
"""
if not proposals:
return {
"health_score": 0, "verdict": "No proposals received from any agent",
"agent_critiques": [], "coverage_gaps": [], "overlaps": [],
"alerts": []
}
by_agent: Dict[str, List[Dict]] = {}
for p in proposals:
by_agent.setdefault(p.get("agent", "unknown"), []).append(p)
# 1. Critique each agent
agent_critiques = []
for agent_name, agent_props in sorted(by_agent.items()):
critique = self._critique_agent(agent_name, agent_props)
agent_critiques.append(critique)
# 2. Coverage check
coverage_gaps = self._find_coverage_gaps(proposals)
overstuffed = self._find_overstuffed_pillars(proposals)
# 3. Overlap detection
overlaps = self._find_overlaps(proposals)
# 4. Overall health score
health_score = self._compute_health_score(agent_critiques, coverage_gaps, overlaps)
# 5. Generate actionable alerts
alerts = self._generate_alerts(agent_critiques, coverage_gaps, overlaps)
verdict = self._verdict_text(health_score, agent_critiques, coverage_gaps)
return {
"health_score": health_score,
"verdict": verdict,
"agent_critiques": agent_critiques,
"coverage_gaps": coverage_gaps,
"overstuffed_pillars": overstuffed,
"overlaps": overlaps,
"alerts": alerts,
"audit_timestamp": datetime.utcnow().isoformat(),
}
# ── agent critique ────────────────────────────────────────────
def _critique_agent(self, agent_name: str, proposals: List[Dict]) -> Dict[str, Any]:
info = KNOWN_AGENTS.get(agent_name, {"label": agent_name, "short": agent_name[:6], "pillar_focus": None})
total = len(proposals)
accepted = sum(1 for p in proposals if p.get("accepted"))
rejected = total - accepted
acceptance_rate = accepted / total if total > 0 else 0
weak_reasoning = []
poor_priority = []
off_pillar = []
for p in proposals:
# Reasoning quality
reason = (p.get("reasoning") or "").strip()
r_score = self._reasoning_score(reason)
if r_score < 0.5:
weak_reasoning.append({"title": p.get("title",""), "reasoning": reason, "score": r_score})
# Priority appropriateness
pr = (p.get("priority") or "").lower()
if info["pillar_focus"] and pr == "low" and p.get("pillar_id") == info["pillar_focus"]:
poor_priority.append({"title": p.get("title",""), "pillar": p.get("pillar_id",""), "priority": pr,
"note": f"Pillar '{info['pillar_focus']}' is {info['label']}'s core — low priority seems wrong"})
# Pillar relevance
if info["pillar_focus"] and p.get("pillar_id") and p["pillar_id"] != info["pillar_focus"]:
off_pillar.append({"title": p.get("title",""), "proposed_pillar": p.get("pillar_id",""),
"expected_pillar": info["pillar_focus"],
"note": f"'{info['label']}' proposed for '{p['pillar_id']}' pillar but typically operates in '{info['pillar_focus']}'"})
issues = []
if weak_reasoning:
issues.append({"type": "weak_reasoning", "severity": "warning", "count": len(weak_reasoning),
"summary": f"{len(weak_reasoning)} proposal(s) with vague or empty reasoning",
"details": weak_reasoning,
"action_label": "Improve reasoning", "action_url": None})
if poor_priority:
issues.append({"type": "poor_priority", "severity": "warning", "count": len(poor_priority),
"summary": f"{len(poor_priority)} proposal(s) under-prioritised for core pillar",
"details": poor_priority,
"action_label": "Review priorities", "action_url": None})
if off_pillar:
issues.append({"type": "off_pillar", "severity": "info", "count": len(off_pillar),
"summary": f"{len(off_pillar)} proposal(s) outside usual pillar",
"details": off_pillar,
"action_label": "Review pillar assignment", "action_url": None})
if rejected > 0:
issues.append({"type": "rejected_proposals", "severity": "error" if acceptance_rate < 0.3 else "warning",
"count": rejected,
"summary": f"{rejected} proposal(s) rejected by committee" if rejected > 0 else "",
"details": [{"title": p.get("title",""), "reason": p.get("rejected_reason","no reason")} for p in proposals if not p.get("accepted")],
"action_label": "Review rejections", "action_url": None})
# Agent score (0-100)
score = 100
if weak_reasoning: score -= len(weak_reasoning) * 15
if poor_priority: score -= len(poor_priority) * 10
if acceptance_rate < 0.3: score -= 20
if acceptance_rate == 0: score = max(0, score - 30)
score = max(0, min(100, score))
health = "good" if score >= 80 else "warning" if score >= 50 else "failing"
return {
"agent": agent_name,
"label": info["label"],
"short": info["short"],
"score": score,
"health": health,
"total_proposals": total,
"accepted": accepted,
"rejected": rejected,
"acceptance_rate": round(acceptance_rate, 2),
"issues": issues,
"summary": self._agent_summary(health, score, accepted, total, weak_reasoning, poor_priority),
}
# ── reasoning quality ─────────────────────────────────────────
def _reasoning_score(self, reasoning: str) -> float:
if not reasoning or len(reasoning) < 10:
return 0.0
# Short = weak
if len(reasoning) < 25:
return 0.2
if len(reasoning) < 50:
return 0.4
# Has specifics
specifics = ["because", "since", "based on", "data", "metric", "trend", "observed",
"target", "audience", "competitor", "gap", "opportunity", "improve",
"increase", "reduce", "goal", "kpi", "score", "result"]
found = sum(1 for s in specifics if s in reasoning.lower())
base = min(1.0, 0.4 + found * 0.1)
# Length bonus
if len(reasoning) > 100:
base = min(1.0, base + 0.15)
return min(1.0, base)
# ── coverage ──────────────────────────────────────────────────
def _find_coverage_gaps(self, proposals: List[Dict]) -> List[Dict]:
covered = set()
for p in proposals:
pid = p.get("pillar_id")
if pid and pid in PILLAR_IDS:
covered.add(pid)
gaps = []
for pid in sorted(PILLAR_IDS):
if pid not in covered:
gaps.append({"pillar_id": pid, "severity": "warning",
"summary": f"Pillar '{pid}' has no proposals from any agent",
"action_label": "Add task", "action_url": None})
return gaps
def _find_overstuffed_pillars(self, proposals: List[Dict]) -> List[Dict]:
counts: Dict[str, int] = {}
for p in proposals:
pid = p.get("pillar_id")
if pid and pid in PILLAR_IDS:
counts[pid] = counts.get(pid, 0) + 1
total = len(proposals)
overstuffed = []
for pid, count in sorted(counts.items()):
if total > 0 and count / total > 0.5:
overstuffed.append({"pillar_id": pid, "count": count, "total": total,
"severity": "info",
"summary": f"Pillar '{pid}' has {count}/{total} proposals ({count/total*100:.0f}%) — may be over-represented",
"action_label": None, "action_url": None})
return overstuffed
# ── overlap detection ─────────────────────────────────────────
def _find_overlaps(self, proposals: List[Dict]) -> List[Dict]:
overlaps = []
by_title: Dict[str, List[Dict]] = {}
for p in proposals:
t = (p.get("title") or "").strip().lower()
by_title.setdefault(t, []).append(p)
for title, dups in by_title.items():
if len(dups) > 1 and title:
agents = [d.get("agent","?") for d in dups]
overlaps.append({"title": dups[0].get("title",""), "pillar": dups[0].get("pillar_id",""),
"agents": agents, "count": len(dups),
"severity": "warning",
"summary": f"{len(dups)} agents proposed '{dups[0].get('title','')}': {', '.join(agents)}",
"action_label": "Resolve conflict", "action_url": None})
return overlaps
# ── health ────────────────────────────────────────────────────
def _compute_health_score(self, critiques: List[Dict], gaps: List[Dict], overlaps: List[Dict]) -> int:
score = 100
for c in critiques:
if c["health"] == "failing": score -= 15
elif c["health"] == "warning": score -= 8
score -= len(gaps) * 10
score -= len(overlaps) * 5
return max(0, min(100, score))
def _verdict_text(self, health: int, critiques: List[Dict], gaps: List[Dict]) -> str:
if health >= 90:
return "Committee is performing well — all agents submitting quality proposals with good coverage."
failing = [c for c in critiques if c["health"] == "failing"]
warning = [c for c in critiques if c["health"] == "warning"]
parts = []
if failing:
parts.append(f"{len(failing)} agent(s) need attention: {', '.join(c['label'] for c in failing)}")
if warning:
parts.append(f"{len(warning)} agent(s) showing issues: {', '.join(c['label'] for c in warning)}")
if gaps:
parts.append(f"Missing coverage: {', '.join(g['pillar_id'] for g in gaps)}")
if not parts:
parts.append("Minor issues detected — monitoring.")
return "".join(parts)
def _agent_summary(self, health: str, score: int, accepted: int, total: int, weak: List, poor: List) -> str:
if health == "failing":
return f"Score {score}/100 — {accepted}/{total} accepted, {len(weak)} weak reasoning, {len(poor)} under-prioritised"
if health == "warning":
return f"Score {score}/100 — {accepted}/{total} accepted, {len(weak)} weak reasoning"
return f"Score {score}/100 — {accepted}/{total} accepted"
# ── alerts ────────────────────────────────────────────────────
def _generate_alerts(self, critiques: List[Dict], gaps: List[Dict], overlaps: List[Dict]) -> List[Dict]:
alerts = []
for c in critiques:
if c["health"] == "failing":
alerts.append({
"type": "agent_failing", "severity": "error",
"agent": c["agent"], "label": c["label"],
"title": f"{c['label']} needs attention",
"message": c["summary"],
"cta_path": None,
})
for issue in c.get("issues", []):
if issue["type"] == "weak_reasoning" and issue["count"] >= 3:
alerts.append({
"type": "weak_reasoning", "severity": "warning",
"agent": c["agent"], "label": c["label"],
"title": f"{c['label']}: {issue['count']} proposals with weak reasoning",
"message": issue["summary"],
"cta_path": None,
})
for g in gaps:
alerts.append({
"type": "coverage_gap", "severity": "warning",
"agent": None, "label": None,
"title": f"Coverage gap: pillar '{g['pillar_id']}'",
"message": g["summary"],
"cta_path": None,
})
for o in overlaps:
alerts.append({
"type": "proposal_overlap", "severity": "warning",
"agent": None, "label": None,
"title": f"Duplicate proposal: '{o['title']}'",
"message": o["summary"],
"cta_path": None,
})
return alerts

View File

@@ -294,21 +294,95 @@ class ContentStrategyAgent(BaseALwrityAgent):
async def propose_daily_tasks(self, context: Dict[str, Any]) -> List[TaskProposal]:
"""
Propose strategic tasks based on content analysis.
Propose strategic tasks based on user onboarding context.
Derives content pillars, industry, and competitor info to
generate personalized daily content suggestions.
"""
proposals = []
# 1. Content Refresh
onboarding = context.get("onboarding_data", {})
if not isinstance(onboarding, dict):
return proposals
# Extract user profile hints from onboarding data
industry = ""
content_pillars = []
competitor_domains = []
try:
cp = onboarding.get("core_persona") or {}
if isinstance(cp, dict):
industry = str(cp.get("industry") or cp.get("company_type") or "")
step2 = onboarding.get("step2_summary") or onboarding.get("industry_context") or {}
if isinstance(step2, dict):
content_pillars = (
step2.get("content_pillars")
or step2.get("topics")
or onboarding.get("content_pillars")
or []
)
cf = onboarding.get("competitor_focus") or {}
if isinstance(cf, dict):
competitor_domains = cf.get("top_competitor_domains") or []
except Exception:
pass
# Task 1: Create content for a key pillar (generate)
if content_pillars:
pillar_topic = content_pillars[0] if isinstance(content_pillars[0], str) else (
content_pillars[0].get("topic") or content_pillars[0].get("name") or "your audience"
)
proposals.append(TaskProposal(
title=f"Create content for '{pillar_topic}'",
description=f"Write a blog post or social content around your {pillar_topic} content pillar.",
pillar_id="generate",
priority="high",
estimated_time=45,
source_agent="ContentStrategyAgent",
reasoning=f"'{pillar_topic}' is a core content pillar in your strategy. Regular publishing keeps your topical authority growing.",
action_type="navigate",
action_url="/blog-writer",
context_data={"pillar_topic": pillar_topic, "industry": industry},
))
else:
proposals.append(TaskProposal(
title="Define your content pillars",
description="Set up your core content topics to get personalized daily suggestions.",
pillar_id="plan",
priority="high",
estimated_time=20,
source_agent="ContentStrategyAgent",
reasoning="Content pillars drive every other task in your workflow. Defining them unlocks the full agent committee.",
action_type="navigate",
action_url="/content-planning-dashboard",
))
# Task 2: Competitor content review (analyze)
if competitor_domains:
domain = competitor_domains[0]
proposals.append(TaskProposal(
title=f"Review competitor: {domain}",
description=f"Analyze recently published content from {domain} to find gaps and opportunities.",
pillar_id="analyze",
priority="medium",
estimated_time=25,
source_agent="ContentStrategyAgent",
reasoning=f"{domain} is your top tracked competitor. Regular reviews help you stay ahead of their content strategy moves.",
action_type="navigate",
action_url="/seo-dashboard",
context_data={"competitor_domain": domain},
))
# Task 3: Content audit (analyze) — always suggested
proposals.append(TaskProposal(
title="Refresh 'SEO Basics'",
description="Update your SEO basics guide with 2024 trends.",
pillar_id="create",
priority="high",
estimated_time=45,
title="Quick content performance audit",
description="Review your top 3 pieces from last month. Identify what worked and what to update.",
pillar_id="analyze",
priority="medium",
estimated_time=20,
source_agent="ContentStrategyAgent",
reasoning="Declining traffic and outdated references.",
reasoning="Regular audits surface declining pages that need refreshing and winning formats to double down on.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url="/content-planning-dashboard",
))
return proposals

View File

@@ -168,25 +168,25 @@ class SEOOptimizationAgent(BaseALwrityAgent):
proposals.append(TaskProposal(
title="Review SEO Issues",
description=f"SIF indexed content suggests {issues_found} areas that may need SEO attention.",
pillar_id="distribute",
pillar_id="analyze",
priority="high",
estimated_time=30,
source_agent="SEOOptimizationAgent",
reasoning="Addressing SEO gaps improves organic visibility.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url="/seo-dashboard"
))
else:
proposals.append(TaskProposal(
title="Run SEO Audit",
description="Perform a comprehensive SEO audit to identify optimization opportunities.",
pillar_id="distribute",
pillar_id="analyze",
priority="medium",
estimated_time=15,
source_agent="SEOOptimizationAgent",
reasoning="Regular audits prevent SEO degradation.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url="/seo-dashboard"
))
return proposals

View File

@@ -126,21 +126,85 @@ class SocialAmplificationAgent(BaseALwrityAgent):
async def propose_daily_tasks(self, context: Dict[str, Any]) -> List[TaskProposal]:
"""
Propose social media tasks.
Propose social media tasks based on user's onboarding context.
Derives platforms and content types from user data.
"""
proposals = []
# 1. Social Post Creation
onboarding = context.get("onboarding_data", {})
if not isinstance(onboarding, dict):
return proposals
# Extract selected platforms from onboarding step 5
selected_platforms = []
try:
step5 = onboarding.get("step5_summary") or onboarding.get("distribution_channels") or {}
if isinstance(step5, dict):
sp = step5.get("selected_platforms") or step5.get("platforms") or []
selected_platforms = [p for p in sp if isinstance(p, str)]
if not selected_platforms:
# Fallback: check top-level keys
for key in ("selected_platforms", "platforms", "social_platforms"):
val = onboarding.get(key)
if isinstance(val, list):
selected_platforms = [p for p in val if isinstance(p, str)]
break
except Exception:
pass
platform_urls = {
"linkedin": "/linkedin-writer",
"facebook": "/facebook-writer",
"twitter": "/linkedin-writer", # no dedicated twitter writer, use linkedin as fallback
"instagram": "/linkedin-writer",
"tiktok": "/linkedin-writer",
"youtube": "/linkedin-writer",
}
target_platforms = [p for p in selected_platforms if p.lower() in platform_urls]
if not target_platforms:
# No known platforms configured — generic engage task
proposals.append(TaskProposal(
title="Share content on social media",
description="Promote your latest published piece across your social channels.",
pillar_id="engage",
priority="medium",
estimated_time=20,
source_agent="SocialAmplificationAgent",
reasoning="Social distribution drives referral traffic and builds audience engagement.",
action_type="navigate",
action_url="/linkedin-writer",
))
return proposals
platform = target_platforms[0]
platform_label = platform.capitalize()
proposals.append(TaskProposal(
title="Create LinkedIn Thread",
description="Summarize your latest blog post into a 5-tweet thread.",
pillar_id="distribute",
title=f"Share content on {platform_label}",
description=f"Adapt and publish your latest content as a {platform_label} post to drive engagement.",
pillar_id="engage",
priority="medium",
estimated_time=20,
source_agent="SocialAmplificationAgent",
reasoning="Repurpose existing content.",
reasoning=f"Consistent {platform_label} posting maintains audience engagement and extends content reach.",
action_type="navigate",
action_url="/content-planning-dashboard"
action_url=platform_urls[platform.lower()],
context_data={"platform": platform.lower()},
))
if len(target_platforms) > 1:
platform2 = target_platforms[1]
proposals.append(TaskProposal(
title=f"Cross-post to {platform2.capitalize()}",
description=f"Repurpose your latest content for your {platform2.capitalize()} audience.",
pillar_id="engage",
priority="low",
estimated_time=15,
source_agent="SocialAmplificationAgent",
reasoning=f"Cross-posting to {platform2.capitalize()} increases reach without additional content creation cost.",
action_type="navigate",
action_url=platform_urls[platform2.lower()],
context_data={"platform": platform2.lower()},
))
return proposals

View File

@@ -587,334 +587,6 @@ class StrategyArchitectAgent(SIFBaseAgent):
return samples
class ContentGuardianAgent(SIFBaseAgent):
"""Agent for preventing cannibalization and ensuring content originality."""
CANNIBALIZATION_THRESHOLD = 0.85 # Similarity threshold for cannibalization warning
ORIGINALITY_THRESHOLD = 0.75 # Minimum originality score
def __init__(self, intelligence_service: TxtaiIntelligenceService, user_id: str, sif_service: Any = None):
super().__init__(intelligence_service, user_id, agent_type="content_guardian")
self.sif_service = sif_service
async def perform_site_audit(self, website_url: str) -> Dict[str, Any]:
"""
Perform a comprehensive content audit on the indexed website content.
Called by the SIF indexing executor after content sync completes.
Returns a structured audit report with quality, brand voice, and safety assessments.
"""
self._log_agent_operation("Performing site audit", website_url=website_url)
try:
# Search the user's SIF index for website content
results = await self.intelligence.search(
f"website content analysis {website_url}", limit=10
)
audit: Dict[str, Any] = {
"website_url": website_url,
"audit_timestamp": datetime.utcnow().isoformat(),
"total_pages_crawled": len(results),
"content_quality": None,
"brand_voice_consistency": None,
"safety_issues": None,
"cannibalization_issues": None,
}
if not results:
logger.warning(f"[{self.__class__.__name__}] No indexed content found for {website_url}")
return audit
# Run assessments on each indexed page
quality_scores = []
style_scores = []
safety_flags = []
for result in results:
text = result.get("text", "") or result.get("id", "")
if len(text) < 50:
continue
quality = await self.assess_content_quality({"description": text, "title": website_url})
quality_scores.append(quality.get("score", 0.0))
style = await self.style_enforcer(text)
style_scores.append(style.get("compliance_score", 0.0))
safety = await self.safety_filter(text)
if not safety.get("is_safe", True):
safety_flags.append(safety.get("flags", []))
audit["content_quality"] = {
"score": round(sum(quality_scores) / max(len(quality_scores), 1), 4),
"pages_analyzed": len(quality_scores),
}
audit["brand_voice_consistency"] = {
"compliance_score": round(sum(style_scores) / max(len(style_scores), 1), 4),
"pages_checked": len(style_scores),
}
audit["safety_issues"] = {
"has_issues": len(safety_flags) > 0,
"flagged_pages": len(safety_flags),
}
cannibalization = await self.check_cannibalization(website_url)
audit["cannibalization_issues"] = cannibalization
logger.info(
f"[{self.__class__.__name__}] Site audit complete for {website_url}: "
f"quality={audit['content_quality']['score']}, "
f"brand_voice={audit['brand_voice_consistency']['compliance_score']}"
)
return audit
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Site audit failed for {website_url}: {e}")
return {
"website_url": website_url,
"error": str(e),
"audit_timestamp": datetime.utcnow().isoformat(),
}
async def assess_content_quality(self, website_data: Dict[str, Any]) -> Dict[str, Any]:
"""Assess overall content quality based on website data."""
self._log_agent_operation("Assessing content quality")
try:
# Extract sample text or description from website_data
text_to_analyze = website_data.get('description', '') or website_data.get('title', '')
if not text_to_analyze:
return {"score": 0.5, "reason": "No content to analyze"}
# Run style check
style_result = await self.style_enforcer(text_to_analyze)
# Run safety check
safety_result = await self.safety_filter(text_to_analyze)
# Calculate aggregate score
base_score = style_result.get('compliance_score', 0.8)
if safety_result.get('action') == 'flag_for_review':
base_score *= 0.5
return {
"score": base_score,
"style_analysis": style_result,
"safety_analysis": safety_result,
"analyzed_text_length": len(text_to_analyze)
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Quality assessment failed: {e}")
return {"score": 0.0, "error": str(e)}
async def check_cannibalization(self, new_draft: str) -> Dict[str, Any]:
"""Check if a new draft competes semantically with existing pages."""
self._log_agent_operation("Checking for semantic cannibalization", draft_length=len(new_draft))
try:
if not await self._ensure_intelligence_ready():
logger.error(f"[{self.__class__.__name__}] Intelligence service not initialized")
return {"warning": False, "error": "Service not initialized"}
if not new_draft or len(new_draft.strip()) < 50:
logger.warning(f"[{self.__class__.__name__}] Draft too short for meaningful analysis")
return {"warning": False, "reason": "Draft too short"}
results = await self.intelligence.search(new_draft, limit=1)
if not results:
logger.info(f"[{self.__class__.__name__}] No similar content found - draft is unique")
return {"warning": False, "uniqueness_score": 1.0}
top_result = results[0]
similarity_score = top_result.get('score', 0.0)
logger.debug(f"[{self.__class__.__name__}] Top similarity score: {similarity_score:.4f}")
if similarity_score > self.CANNIBALIZATION_THRESHOLD:
warning_data = {
"warning": True,
"similar_to": top_result.get('id', 'unknown'),
"score": similarity_score,
"threshold": self.CANNIBALIZATION_THRESHOLD,
"recommendation": "Consider revising the draft to target a different angle or merge with existing content"
}
logger.warning(f"[{self.__class__.__name__}] Cannibalization detected: {warning_data}")
return warning_data
logger.info(f"[{self.__class__.__name__}] No cannibalization detected. Draft is sufficiently unique.")
return {"warning": False, "uniqueness_score": 1.0 - similarity_score}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Failed to check cannibalization: {e}")
logger.error(f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}")
return {"warning": False, "error": str(e)}
async def verify_originality(self, text: str, competitor_index: Any) -> Dict[str, Any]:
"""Verify originality against competitor content index."""
self._log_agent_operation("Verifying originality against competitors", text_length=len(text))
try:
if not text or len(text.strip()) < 50:
logger.warning(f"[{self.__class__.__name__}] Text too short for meaningful originality check")
return {"originality_score": 0.0, "reason": "Text too short"}
query = text.strip()
competitor_results = []
method = "user_index_competitor_filter"
if competitor_index is not None and hasattr(competitor_index, "search"):
method = "competitor_index_search"
raw_results = competitor_index.search(query, limit=5)
if asyncio.iscoroutine(raw_results):
raw_results = await raw_results
competitor_results = raw_results or []
else:
raw_results = await self.intelligence.search(query, limit=10)
for result in raw_results or []:
metadata_raw = result.get("object")
metadata = metadata_raw if isinstance(metadata_raw, dict) else {}
if not metadata and isinstance(metadata_raw, str):
try:
metadata = json.loads(metadata_raw)
except Exception:
metadata = {}
doc_type = str((metadata or {}).get("type", "")).lower()
source = str((metadata or {}).get("source", "")).lower()
if "competitor" in doc_type or "competitor" in source:
competitor_results.append(result)
if not competitor_results:
return {
"originality_score": 1.0,
"confidence": 0.6,
"method": method,
"notes": "No competitor overlap detected in available index"
}
top_match = max(competitor_results, key=lambda item: float(item.get("score", 0.0)))
top_score = max(0.0, min(1.0, float(top_match.get("score", 0.0))))
originality_score = max(0.0, round(1.0 - top_score, 4))
confidence = round(min(1.0, 0.55 + (min(len(competitor_results), 5) * 0.07)), 3)
warning = originality_score < self.ORIGINALITY_THRESHOLD
return {
"originality_score": originality_score,
"confidence": confidence,
"method": method,
"warning": warning,
"threshold": self.ORIGINALITY_THRESHOLD,
"top_competitor_match": {
"id": top_match.get("id"),
"score": round(top_score, 4)
},
"matches_evaluated": len(competitor_results)
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Failed to verify originality: {e}")
logger.error(f"[{self.__class__.__name__}] Full traceback: {traceback.format_exc()}")
return {"originality_score": 0.0, "error": str(e)}
async def style_enforcer(self, text: str, style_guidelines: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Tool: Ensures content adheres to brand voice and style guidelines.
"""
self._log_agent_operation("Enforcing style guidelines", text_length=len(text))
try:
if not text:
return {"compliance_score": 0.0, "issues": ["No text provided"]}
# 1. Fetch Style Guidelines from SIF if not provided
if not style_guidelines and self.sif_service:
try:
# Search for website analysis to get brand voice/style
# We assume the most relevant 'website_analysis' doc contains the guidelines
results = await self.intelligence.search("website analysis brand voice style", limit=1)
if results:
import json
res = results[0]
metadata_str = res.get('object')
metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else (metadata_str or res)
if metadata.get('type') == 'website_analysis':
report = metadata.get('full_report', {})
style_guidelines = {
"tone": report.get('brand_analysis', {}).get('brand_voice', 'neutral'),
"style_patterns": report.get('style_patterns', {}),
"writing_style": report.get('writing_style', {})
}
logger.info(f"[{self.__class__.__name__}] Retrieved style guidelines from SIF: {style_guidelines.get('tone')}")
except Exception as e:
logger.warning(f"[{self.__class__.__name__}] Failed to retrieve style guidelines from SIF: {e}")
issues = []
score = 1.0
# Basic Heuristic Checks (Placeholder for LLM-based style analysis)
# 1. Tone Check (e.g., formal vs casual)
# If guidelines specify 'formal', check for contractions
tone = style_guidelines.get('tone', '').lower() if style_guidelines else ''
if 'formal' in tone or 'professional' in tone:
contractions = ["can't", "won't", "don't", "it's"]
found_contractions = [c for c in contractions if c in text.lower()]
if found_contractions:
issues.append(f"Found contractions in formal text: {', '.join(found_contractions[:3])}...")
score -= 0.1
# 2. Length/Sentence Structure (simple metric)
sentences = text.split('.')
avg_len = sum(len(s.split()) for s in sentences if s) / max(1, len(sentences))
if avg_len > 25:
issues.append("Average sentence length is too high (>25 words). Consider shortening.")
score -= 0.1
return {
"compliance_score": max(0.0, score),
"issues": issues,
"is_compliant": score > 0.8,
"guidelines_source": "sif_index" if not style_guidelines and self.sif_service else "provided"
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Style enforcement failed: {e}")
return {"error": str(e)}
async def safety_filter(self, text: str) -> Dict[str, Any]:
"""
Tool: Flags potentially harmful, offensive, or sensitive content.
"""
self._log_agent_operation("Running safety filter", text_length=len(text))
try:
# Basic Keyword Blocklist (Placeholder for LLM/Safety Model)
# In production, this should call a dedicated safety API (e.g., OpenAI Moderation, Llama Guard)
unsafe_keywords = [
"hate", "kill", "murder", "attack", "destroy", # Violent
"scam", "fraud", "steal", # Illegal
"explicit", "adult" # NSFW
]
found_flags = []
text_lower = text.lower()
for keyword in unsafe_keywords:
if f" {keyword} " in text_lower: # Simple word boundary check
found_flags.append(keyword)
is_safe = len(found_flags) == 0
return {
"is_safe": is_safe,
"flags": found_flags,
"safety_score": 1.0 if is_safe else 0.0,
"action": "approve" if is_safe else "flag_for_review"
}
except Exception as e:
logger.error(f"[{self.__class__.__name__}] Safety filter failed: {e}")
return {"error": str(e)}
class LinkGraphAgent(SIFBaseAgent):
"""

View File

@@ -375,9 +375,13 @@ def llm_text_gen(
system_prompt=system_instructions
)
elif gpt_provider == "wavespeed":
llm_start = time.time()
t0 = time.time()
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: Starting provider init for user {user_id}")
if json_struct:
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: Importing wavespeed_provider module (lazy import) for user {user_id}")
from services.llm_providers.wavespeed_provider import wavespeed_structured_json_response
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: Import done, making API call for user {user_id}, import_took={(time.time()-t0)*1000:.0f}ms")
t1 = time.time()
response_text = wavespeed_structured_json_response(
prompt=prompt,
schema=json_struct,
@@ -387,7 +391,10 @@ def llm_text_gen(
system_prompt=system_instructions
)
else:
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: Importing wavespeed_provider module (lazy import) for user {user_id}")
from services.llm_providers.wavespeed_provider import wavespeed_text_response
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: Import done, making API call for user {user_id}, import_took={(time.time()-t0)*1000:.0f}ms")
t1 = time.time()
response_text = wavespeed_text_response(
prompt=prompt,
model=model or "openai/gpt-oss-120b",
@@ -396,8 +403,9 @@ def llm_text_gen(
top_p=top_p,
system_prompt=system_instructions
)
llm_ms = (time.time() - llm_start) * 1000
logger.warning(f"[llm_text_gen][{flow_tag}] LLM API call took {llm_ms:.0f}ms for user {user_id} (wavespeed)")
api_took_ms = (time.time() - t1) * 1000
total_ms = (time.time() - t0) * 1000
logger.warning(f"[llm_text_gen][{flow_tag}] wavespeed: user={user_id} import_took={(t1-t0)*1000:.0f}ms api_took={api_took_ms:.0f}ms total={total_ms:.0f}ms")
else:
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
raise RuntimeError(f"Unknown LLM provider: {gpt_provider}. Supported providers: google, huggingface, wavespeed")

View File

@@ -38,6 +38,7 @@ Last Updated: March 2026
import os
import sys
import time as _time
from pathlib import Path
import json
import re
@@ -46,15 +47,16 @@ from typing import Optional, Dict, Any, List
from dotenv import load_dotenv
# Fix the environment loading path - load from backend directory
_mod_start = _time.time()
current_dir = Path(__file__).parent.parent # services directory
backend_dir = current_dir.parent # backend directory
env_path = backend_dir / '.env'
if env_path.exists():
load_dotenv(env_path)
print(f"Loaded .env from: {env_path}")
_dotenv_ms = (_time.time() - _mod_start) * 1000
print(f"Loaded .env from: {env_path} (took {_dotenv_ms:.0f}ms)")
else:
# Fallback to current directory
load_dotenv()
print(f"No .env found at {env_path}, using current directory")
@@ -64,6 +66,7 @@ from utils.logger_utils import get_service_logger
# Use service-specific logger to avoid conflicts
logger = get_service_logger("wavespeed_provider")
_import_start = _time.time()
from tenacity import (
retry,
retry_if_exception,
@@ -80,6 +83,8 @@ except ImportError:
NotFoundError = Exception
logger.warn("OpenAI library not available. Install with: pip install openai")
logger.warning(f"[wavespeed_provider] Module import completed in {(_time.time()-_import_start)*1000:.0f}ms (openai_available={OPENAI_AVAILABLE})")
# Default WaveSpeed models for fallback
WAVESPEED_FALLBACK_MODELS = [
"openai/gpt-oss-120b",
@@ -276,12 +281,13 @@ def wavespeed_text_response(
if not api_key:
raise Exception("WAVESPEED_API_KEY not found in environment variables")
_t0 = _time.time()
# Initialize WaveSpeed client
client = OpenAI(
base_url="https://llm.wavespeed.ai/v1",
api_key=api_key,
)
logger.info("✅ WaveSpeed client initialized for text response")
logger.warning(f"[wavespeed_text_response] OpenAI client init took {(_time.time()-_t0)*1000:.0f}ms")
# Prepare input for the API
messages = []
@@ -311,6 +317,7 @@ def wavespeed_text_response(
logger.info("🚀 Making WaveSpeed API call (chat completion)...")
_api_t0 = _time.time()
# Call exactly the requested model; no retries, no fallbacks, no variants
response = client.chat.completions.create(
model=model,
@@ -319,6 +326,7 @@ def wavespeed_text_response(
top_p=top_p,
max_tokens=max_tokens
)
logger.warning(f"[wavespeed_text_response] API call took {(_time.time()-_api_t0)*1000:.0f}ms")
# Extract text from response
generated_text = response.choices[0].message.content
@@ -422,13 +430,15 @@ def wavespeed_structured_json_response(
if not api_key:
raise Exception("WAVESPEED_API_KEY not found in environment variables")
_fn_start = _time.time()
# Initialize OpenAI client with WaveSpeed base URL
client = OpenAI(
base_url="https://llm.wavespeed.ai/v1",
api_key=api_key,
)
logger.info("✅ WaveSpeed client initialized for structured JSON response")
_client_init_ms = (_time.time() - _fn_start) * 1000
logger.warning(f"[wavespeed_structured_json_response] OpenAI client init took {_client_init_ms:.0f}ms")
# Prepare input for the API
messages = []
@@ -463,11 +473,13 @@ def wavespeed_structured_json_response(
json_schema_str = json.dumps(schema, indent=2)
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
_api_start = _time.time()
try:
response = None
last_error = None
for candidate_model in _fallback_model_sequence(model, fallback_models):
try:
logger.info(f"[wavespeed_structured_json_response] Calling model={candidate_model}...")
response = client.chat.completions.create(
model=candidate_model,
messages=messages,
@@ -475,8 +487,10 @@ def wavespeed_structured_json_response(
max_tokens=max_tokens,
response_format={"type": "json_object"} # Try to enforce JSON mode if supported
)
_api_ms = (_time.time() - _api_start) * 1000
if candidate_model != model:
logger.warning("WaveSpeed structured generation switched to fallback model: {}", candidate_model)
logger.warning(f"[wavespeed_structured_json_response] First API call completed in {_api_ms:.0f}ms (model={candidate_model})")
break
except NotFoundError as nf_err:
last_error = nf_err

View File

@@ -168,3 +168,74 @@ class OnboardingProgressService:
except Exception as e:
logger.error(f"Error completing onboarding: {e}")
return False
def reset_onboarding(self, user_id: str) -> bool:
"""Reset onboarding progress and cancel/pause all scheduled tasks for the user."""
try:
db = get_session_for_user(user_id)
try:
# Reset the onboarding session
session = db.query(OnboardingSession).filter(OnboardingSession.user_id == user_id).first()
if session:
session.current_step = 1
session.progress = 0.0
session.updated_at = datetime.utcnow()
db.commit()
finally:
db.close()
# Cancel/pause all scheduled tasks for this user
self._cancel_scheduled_tasks(user_id)
logger.info(f"Reset onboarding for user {user_id}")
return True
except Exception as e:
logger.error(f"Error resetting onboarding for user {user_id}: {e}")
return False
def _cancel_scheduled_tasks(self, user_id: str):
"""Pause all DB-backed scheduled tasks for a user after onboarding reset."""
try:
from models.website_analysis_monitoring_models import (
OnboardingFullWebsiteAnalysisTask,
DeepCompetitorAnalysisTask,
SIFIndexingTask,
MarketTrendsTask,
WebsiteAnalysisTask,
)
from models.advertools_monitoring_models import AdvertoolsTask
db = get_session_for_user(user_id)
try:
task_models = [
OnboardingFullWebsiteAnalysisTask,
DeepCompetitorAnalysisTask,
SIFIndexingTask,
MarketTrendsTask,
WebsiteAnalysisTask,
]
try:
task_models.append(AdvertoolsTask)
except Exception:
pass
paused_count = 0
for model_cls in task_models:
try:
active_tasks = db.query(model_cls).filter(
model_cls.user_id == user_id,
model_cls.status == "active"
).all()
for task in active_tasks:
task.status = "paused"
paused_count += 1
except Exception as e:
logger.warning(f"Could not pause {model_cls.__tablename__} tasks for user {user_id}: {e}")
db.commit()
if paused_count > 0:
logger.info(f"Paused {paused_count} scheduled tasks for user {user_id} after onboarding reset")
finally:
db.close()
except Exception as e:
logger.warning(f"Failed to cancel scheduled tasks for user {user_id}: {e}")

View File

@@ -76,7 +76,7 @@ class GoogleSearchService:
logger.info(f"Searching for: {search_query}")
# Perform the search
search_results = await self._perform_search(search_query, max_results)
search_results = await self.perform_search(search_query, max_results)
# Process and rank results
processed_results = await self._process_search_results(search_results, topic, industry)
@@ -140,13 +140,16 @@ class GoogleSearchService:
return " ".join(query_components)
async def _perform_search(self, query: str, max_results: int) -> List[Dict[str, Any]]:
async def perform_search(self, query: str, max_results: int, **overrides) -> List[Dict[str, Any]]:
"""
Perform the actual Google Custom Search API call.
Args:
query: The search query to execute
max_results: Maximum number of results to return
**overrides: Override or disable default params.
Pass `param=None` to remove a default param entirely.
Pass `param=value` to override its value.
Returns:
Raw search results from Google API
@@ -158,8 +161,15 @@ class GoogleSearchService:
"num": min(max_results, 10), # Google CSE max is 10 per request
"dateRestrict": "m1", # Last month
"sort": "date", # Sort by date for current information
"safe": "active" # Safe search for professional content
"safe": "active", # Safe search for professional content
}
# Apply overrides: None removes the key, non-None overrides the value
if overrides:
for k, v in overrides.items():
if v is None:
params.pop(k, None)
else:
params[k] = v
async with aiohttp.ClientSession() as session:
async with session.get(self.base_url, params=params) as response:
@@ -477,7 +487,7 @@ class GoogleSearchService:
try:
# Perform a simple test search
test_query = "AI technology trends 2024"
test_results = await self._perform_search(test_query, 1)
test_results = await self.perform_search(test_query, 1)
return {
"status": "success",

View File

@@ -1,3 +1,4 @@
import asyncio
import time
from datetime import datetime, timedelta
from typing import Any, Dict
@@ -16,6 +17,9 @@ from utils.logger_utils import get_service_logger
logger = get_service_logger("deep_competitor_analysis_executor")
DEEP_COMPETITOR_TIMEOUT_SECONDS = 300 # 5-minute hard timeout
DEEP_COMPETITOR_MAX_COMPETITORS = 10 # cap to reduce API pressure
class DeepCompetitorAnalysisExecutor(TaskExecutor):
def __init__(self):
@@ -82,17 +86,23 @@ class DeepCompetitorAnalysisExecutor(TaskExecutor):
retryable=False
)
max_competitors = int(payload.get("max_competitors") or 25)
max_competitors = min(int(payload.get("max_competitors") or 25), DEEP_COMPETITOR_MAX_COMPETITORS)
crawl_concurrency = int(payload.get("crawl_concurrency") or 4)
mode = payload.get("mode", "deep_analysis")
if mode == "strategic_insights":
logger.info(f"Executing weekly strategic insights for user {user_id}")
report = await self.analysis_service.generate_weekly_strategy_brief(
user_id=user_id,
website_analysis=website_analysis if isinstance(website_analysis, dict) else {},
competitors=competitors
)
try:
report = await asyncio.wait_for(
self.analysis_service.generate_weekly_strategy_brief(
user_id=user_id,
website_analysis=website_analysis if isinstance(website_analysis, dict) else {},
competitors=competitors
),
timeout=DEEP_COMPETITOR_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
raise TimeoutError(f"Strategic insights timed out after {DEEP_COMPETITOR_TIMEOUT_SECONDS}s for user {user_id}")
# Persist to WebsiteAnalysis history
analysis_id = website_analysis.get('id')
@@ -110,13 +120,19 @@ class DeepCompetitorAnalysisExecutor(TaskExecutor):
flag_modified(wa, "strategic_insights_history")
db.commit()
else:
report = await self.analysis_service.run(
user_id=user_id,
website_analysis=website_analysis if isinstance(website_analysis, dict) else {},
competitors=competitors,
max_competitors=max_competitors,
crawl_concurrency=crawl_concurrency
)
try:
report = await asyncio.wait_for(
self.analysis_service.run(
user_id=user_id,
website_analysis=website_analysis if isinstance(website_analysis, dict) else {},
competitors=competitors,
max_competitors=max_competitors,
crawl_concurrency=crawl_concurrency
),
timeout=DEEP_COMPETITOR_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
raise TimeoutError(f"Deep competitor analysis timed out after {DEEP_COMPETITOR_TIMEOUT_SECONDS}s for user {user_id}")
task.last_executed = datetime.utcnow()
task.last_success = datetime.utcnow()

View File

@@ -103,7 +103,7 @@ class SIFIndexingExecutor(TaskExecutor):
guardian_report = None
if content_synced:
try:
from services.intelligence.sif_agents import ContentGuardianAgent
from services.intelligence.agents.specialized import ContentGuardianAgent
# Re-use the intelligence service from sif_service
guardian_agent = ContentGuardianAgent(
intelligence_service=sif_service.intelligence_service,

View File

@@ -9,6 +9,8 @@ from .on_page_seo_service import OnPageSEOService
from .technical_seo_service import TechnicalSEOService
from .enterprise_seo_service import EnterpriseSEOService
from .content_strategy_service import ContentStrategyService
from .serp_gap_service import SerpGapService
from .competitor_content_service import CompetitorContentService
__all__ = [
'MetaDescriptionService',
@@ -20,4 +22,6 @@ __all__ = [
'TechnicalSEOService',
'EnterpriseSEOService',
'ContentStrategyService',
'SerpGapService',
'CompetitorContentService',
]

View File

@@ -0,0 +1,214 @@
"""
Competitor Content Service for ALwrity
Fetches full competitor content for gap topics using Exa with include_domains.
Phase 2 of the Content Gap Radar feature.
Usage:
service = CompetitorContentService()
result = await service.deep_dive(
topics=["AI content strategy"],
competitor_domains=["example.com"]
)
"""
import os
import asyncio
import hashlib
import json
import time
from typing import Dict, List, Optional, Any
from loguru import logger
class CompetitorContentService:
"""
Fetches competitor content for gap topics using Exa neural search.
Uses Exa's `include_domains` to scope searches to known competitor domains,
returning full text, highlights, and summaries for deeper competitive analysis.
Results are cached for 24h to reduce API costs.
Designed to be consumed by the future ContentGapRadarAgent.
"""
CACHE_TTL = int(os.getenv("COMPETITOR_CONTENT_CACHE_TTL", "86400"))
def __init__(self):
self.api_key = os.getenv("EXA_API_KEY")
if not self.api_key:
logger.warning(
"EXA_API_KEY not configured; CompetitorContentService disabled"
)
self._exa = None
self._cache: Dict[str, Dict[str, Any]] = {}
@property
def exa(self):
"""Lazy-init Exa SDK to allow env injection after import."""
if self._exa is None and self.api_key:
from exa_py import Exa
self._exa = Exa(self.api_key)
return self._exa
def _cache_key(self, topics: List[str], domains: List[str]) -> str:
raw = json.dumps(
{"t": sorted(topics), "d": sorted(domains)}, sort_keys=True
)
return hashlib.md5(raw.encode()).hexdigest()
def _get_cached(self, key: str) -> Optional[Dict[str, Any]]:
entry = self._cache.get(key)
if entry and (time.time() - entry["ts"]) < self.CACHE_TTL:
return entry["data"]
return None
def _set_cache(self, key: str, data: Dict[str, Any]):
self._cache[key] = {"data": data, "ts": time.time()}
async def deep_dive(
self,
topics: List[str],
competitor_domains: List[str],
max_total_results: int = 10,
concurrency: int = 3,
bypass_cache: bool = False,
) -> Dict[str, Any]:
"""
Fetch competitor content for a list of gap topics.
For each topic, searches Exa scoped to competitor domains and returns
full text, highlights, and publishing metadata.
Args:
topics: Topic phrases to research (e.g. from SERP gap analysis)
competitor_domains: Known competitor domains to scope search
max_total_results: Max results per topic total (Exa API limit varies)
concurrency: Max concurrent Exa API calls
bypass_cache: Force fresh API calls, ignoring cache
Returns:
Dict with keys:
results: List of per-topic competitor content results
total_topics_analyzed: int
topics_with_content: int
cached: bool
"""
if not topics or not competitor_domains:
return {
"results": [],
"total_topics_analyzed": 0,
"topics_with_content": 0,
"cached": False,
}
ck = self._cache_key(topics, competitor_domains)
if not bypass_cache:
cached = self._get_cached(ck)
if cached:
logger.info("Returning cached competitor content results")
return {**cached, "cached": True}
if not self.api_key or not self.exa:
return {
"results": [],
"total_topics_analyzed": len(topics),
"topics_with_content": 0,
"cached": False,
"error": "EXA_API_KEY not configured",
}
semaphore = asyncio.Semaphore(concurrency)
loop = asyncio.get_running_loop()
async def search_topic(topic: str) -> Dict[str, Any]:
async with semaphore:
return await self._search_single_topic(
topic, competitor_domains, max_total_results, loop
)
tasks = [search_topic(topic) for topic in topics]
results = await asyncio.gather(*tasks)
output = {
"results": results,
"total_topics_analyzed": len(topics),
"topics_with_content": sum(
1 for r in results if r.get("total_results", 0) > 0
),
"cached": False,
}
self._set_cache(ck, output)
return output
async def _search_single_topic(
self,
topic: str,
competitor_domains: List[str],
max_results: int,
loop: asyncio.AbstractEventLoop,
) -> Dict[str, Any]:
"""
Search Exa for a single topic, scoped to competitor domains.
"""
query = topic
search_kwargs = {
"type": "auto",
"num_results": max_results,
"include_domains": competitor_domains,
"text": {"max_characters": 2000},
"highlights": {"num_sentences": 3, "highlights_per_url": 3},
"summary": {"query": f"Key details about {topic}"},
}
try:
results = await loop.run_in_executor(
None,
lambda: self.exa.search_and_contents(query, **search_kwargs),
)
content = []
seen_urls = set()
for result in getattr(results, "results", []) or []:
url = getattr(result, "url", "")
if not url or url in seen_urls:
continue
seen_urls.add(url)
content.append({
"domain": self._extract_domain(url),
"title": getattr(result, "title", "Untitled"),
"url": url,
"highlights": getattr(result, "highlights", []),
"summary": getattr(result, "summary", ""),
"text": getattr(result, "text", ""),
"published_date": getattr(result, "published_date", None),
"author": getattr(result, "author", None),
})
return {
"topic": topic,
"competitor_content": content,
"total_results": len(content),
"domains_found": list(
set(c["domain"] for c in content if c["domain"])
),
}
except Exception as e:
logger.warning(f"Exa search failed for topic '{topic}': {e}")
return {
"topic": topic,
"competitor_content": [],
"total_results": 0,
"domains_found": [],
"error": str(e),
}
@staticmethod
def _extract_domain(url: str) -> str:
"""Extract domain from URL."""
try:
from urllib.parse import urlparse
return urlparse(url).netloc.lower()
except Exception:
return url.lower()

View File

@@ -0,0 +1,175 @@
"""
SERP Gap Service for ALwrity
Detects which competitors rank for target topics using Google Custom Search.
Phase 1 of the Content Gap Radar feature.
Usage:
service = SerpGapService()
result = await service.analyze_topic_gaps(
topics=["AI content strategy", "topic clustering"],
competitor_domains=["example.com", "competitor.org"]
)
"""
import asyncio
import hashlib
import json
import os
import time
from typing import Dict, List, Optional, Any
from loguru import logger
from services.research.google_search_service import GoogleSearchService
class SerpGapService:
"""
SERP Gap Analysis Service.
Uses Google Custom Search `site:` queries to detect competitor ranking presence
for specific topics. Results are cached for 24h to stay within free-tier quotas
(100 queries/day). Designed to be consumed by a future ContentGapRadarAgent
that scores and prioritizes gaps.
"""
CACHE_TTL = int(os.getenv("SERP_GAP_CACHE_TTL", "86400")) # 24 hours default
def __init__(self, google_search_service: Optional[GoogleSearchService] = None):
self.gcs = google_search_service or GoogleSearchService()
self._cache: Dict[str, Dict[str, Any]] = {}
logger.info("SerpGapService initialized")
def _cache_key(self, topics: List[str], domains: List[str]) -> str:
"""Deterministic cache key from sorted topics + domains."""
raw = json.dumps(
{"t": sorted(topics), "d": sorted(domains)}, sort_keys=True
)
return hashlib.md5(raw.encode()).hexdigest()
def _get_cached(self, key: str) -> Optional[Dict[str, Any]]:
entry = self._cache.get(key)
if entry and (time.time() - entry["ts"]) < self.CACHE_TTL:
return entry["data"]
return None
def _set_cache(self, key: str, data: Dict[str, Any]):
self._cache[key] = {"data": data, "ts": time.time()}
async def analyze_topic_gaps(
self,
topics: List[str],
competitor_domains: List[str],
max_results_per_site: int = 5,
concurrency: int = 3,
bypass_cache: bool = False,
) -> Dict[str, Any]:
"""
Analyze SERP gaps for a list of topics across known competitors.
For each topic, queries Google with `site:competitor_domain topic` for
each known competitor to detect ranking presence.
Args:
topics: Topic phrases to check (e.g. from find_semantic_gaps())
competitor_domains: Known competitor domains (e.g. ["example.com"])
max_results_per_site: Max Google CSE results per site: query (max 10)
concurrency: Max concurrent API calls to stay under rate limits
bypass_cache: Force fresh API calls, ignoring cache
Returns:
Dict with keys:
gaps: List of per-topic SERP gap results
total_topics_analyzed: int
total_competitors: int
cached: bool
"""
if not topics or not competitor_domains:
return {
"gaps": [],
"total_topics_analyzed": 0,
"total_competitors": 0,
"cached": False,
}
ck = self._cache_key(topics, competitor_domains)
if not bypass_cache:
cached = self._get_cached(ck)
if cached:
logger.info("Returning cached SERP gap results")
return {**cached, "cached": True}
semaphore = asyncio.Semaphore(concurrency)
async def analyze_topic(topic: str) -> Dict[str, Any]:
async with semaphore:
return await self._analyze_single_topic(
topic, competitor_domains, max_results_per_site
)
tasks = [analyze_topic(topic) for topic in topics]
results = await asyncio.gather(*tasks)
output = {
"gaps": results,
"total_topics_analyzed": len(topics),
"total_competitors": len(competitor_domains),
"cached": False,
}
self._set_cache(ck, output)
return dict(output)
async def _analyze_single_topic(
self,
topic: str,
competitor_domains: List[str],
max_results: int,
) -> Dict[str, Any]:
"""
Check SERP presence for a single topic across all competitor domains.
Removes the dateRestrict and sort=date defaults from Google CSE so we
see all-time competitor content (not just last month).
"""
competitors_found = []
failed_queries = 0
for domain in competitor_domains:
query = f"site:{domain} {topic}"
try:
raw_results = await self.gcs.perform_search(
query,
max_results,
dateRestrict=None, # Don't limit to last month
sort=None, # Use relevance sorting, not date
)
for result in raw_results:
competitors_found.append({
"domain": domain,
"title": result.get("title", ""),
"url": result.get("link", ""),
"snippet": result.get("snippet", ""),
})
except Exception as e:
logger.warning(
f"GCS query failed for site:{domain} topic='{topic}': {e}"
)
failed_queries += 1
continue
seen_urls = set()
unique_competitors = []
for entry in competitors_found:
if entry["url"] not in seen_urls:
seen_urls.add(entry["url"])
unique_competitors.append(entry)
return {
"topic": topic,
"competitors_found": unique_competitors,
"competitor_count": len(unique_competitors),
"domains_with_content": list(
set(e["domain"] for e in unique_competitors)
),
"failed_queries": failed_queries,
"total_domains_checked": len(competitor_domains),
}

View File

@@ -123,13 +123,15 @@ def _is_coverage_guardrail_enabled(grounding: Dict[str, Any]) -> bool:
return True
def _sanitize_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def _sanitize_task(task: Dict[str, Any], agent_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not isinstance(task, dict):
return None
pillar_id = str(task.get("pillarId") or "").lower().strip()
title = str(task.get("title") or "").strip()
if pillar_id not in PILLAR_IDS or not title:
reason = "empty title" if not title else f"invalid pillar_id={pillar_id!r}"
logger.warning(f"Rejected task from agent {agent_name or 'unknown'}: {reason}")
return None
sanitized = dict(task)
@@ -418,6 +420,7 @@ async def generate_agent_enhanced_plan(
orchestrator.agents.get('seo'), # SEOOptimizationAgent
orchestrator.agents.get('social'), # SocialAmplificationAgent
orchestrator.agents.get('competitor'), # CompetitorResponseAgent
orchestrator.agents.get('content_gap_radar'), # ContentGapRadarAgent
]
# Filter out None agents (disabled/failed init)
@@ -466,7 +469,118 @@ async def generate_agent_enhanced_plan(
# Phase 3: Check memory for rejections (Semantic Filter)
agent_tasks = await memory_service.filter_redundant_proposals(agent_tasks)
# Log committee meeting event for frontend transparency
try:
accepted_ids = {f"{p.pillar_id}:{p.title}" for p in agent_tasks}
proposals_log = []
for p in raw_proposals:
valid = p.pillar_id in PILLAR_IDS
key = f"{p.pillar_id}:{p.title}"
proposals_log.append({
"agent": p.source_agent,
"title": p.title,
"pillar_id": p.pillar_id,
"priority": p.priority,
"valid": valid,
"accepted": key in accepted_ids,
"rejected_reason": None if valid else f"pillar_id '{p.pillar_id}' not in {PILLAR_IDS}",
"reasoning": p.reasoning,
"estimated_time": p.estimated_time,
"action_type": p.action_type,
})
if not valid:
logger.warning(
f"Rejected proposal from agent {p.source_agent}: "
f"invalid pillar_id={p.pillar_id!r} (title={p.title!r}). "
f"Must be one of {PILLAR_IDS}"
)
activity.log_event(
event_type="committee_meeting",
message=f"Committee: {len(agent_tasks)}/{len(raw_proposals)} tasks accepted from {len(active_agents)} agents",
payload={
"agents_polled": len(active_agents),
"total_proposals": len(raw_proposals),
"accepted_count": len(agent_tasks),
"rejected_count": len(raw_proposals) - len(agent_tasks),
"proposals": proposals_log,
},
)
except Exception as e:
logger.warning(f"Failed to log committee meeting event: {e}")
# --- Committee Watchdog Audit (ContentGuardianAgent) ---
try:
guardian_agent = orchestrator.agents.get('guardian')
if guardian_agent and hasattr(guardian_agent, 'audit_committee'):
# Build proposals list from committee data (same format as proposals_log above)
accepted_ids = {f"{p.pillar_id}:{p.title}" for p in agent_tasks}
audit_input = []
for p in raw_proposals:
key = f"{p.pillar_id}:{p.title}"
audit_input.append({
"agent": p.source_agent,
"title": p.title,
"pillar_id": p.pillar_id,
"priority": p.priority,
"reasoning": p.reasoning or "",
"accepted": key in accepted_ids,
"valid": p.pillar_id in PILLAR_IDS,
"rejected_reason": None if p.pillar_id in PILLAR_IDS else f"pillar_id '{p.pillar_id}' not in {PILLAR_IDS}",
})
audit_report = await guardian_agent.audit_committee(audit_input)
activity.log_event(
event_type="quality_audit",
message=f"Committee audit: {audit_report['health_score']}/100 health — {len(audit_report['alerts'])} findings",
payload=audit_report,
)
logger.info(
f"Committee audit: health={audit_report['health_score']}, "
f"critiques={len(audit_report['agent_critiques'])}, "
f"gaps={len(audit_report['coverage_gaps'])}, "
f"overlaps={len(audit_report['overlaps'])}"
)
# Create alerts for serious watchdog findings
for alert in audit_report.get("alerts", []):
sev = alert.get("severity", "warning")
dedupe_key = f"guardian:{alert['type']}:{alert.get('agent','')}:{alert.get('title','')}"
try:
activity.create_alert(
alert_type=f"guardian_{alert['type']}",
title=alert["title"],
message=alert["message"],
severity="error" if sev == "error" else "warning",
cta_path=alert.get("cta_path"),
payload={"guardian_agent": alert.get("agent"), "type": alert["type"]},
dedupe_key=dedupe_key,
)
except Exception as ae:
logger.warning(f"Failed to create guardian alert: {ae}")
except Exception as e:
logger.warning(f"Committee watchdog audit failed: {e}")
# --- Trend Signals (TrendSurferAgent) ---
try:
trend_agent = orchestrator.agents.get('trend')
if trend_agent and hasattr(trend_agent, 'surf_trends'):
opportunities = await trend_agent.surf_trends()
if opportunities:
activity.log_event(
event_type="trend_signals",
message=f"Trend signals: {len(opportunities)} opportunities detected",
payload={
"opportunities": opportunities[:5],
"total_detected": len(opportunities),
"scan_timestamp": datetime.utcnow().isoformat(),
},
)
logger.info(f"Logged trend_signals event with {len(opportunities)} opportunities")
except Exception as e:
logger.warning(f"Trend signal phase failed: {e}")
except Exception as e:
logger.error(f"Committee proposal phase failed: {e}")
# Continue to fallback or LLM generation if committee fails
@@ -669,6 +783,12 @@ async def get_or_create_daily_workflow_plan(
for t in tasks:
pillar_id = str(t.get("pillarId") or "").lower().strip()
if pillar_id not in PILLAR_IDS:
agent = None
metadata = t.get("metadata")
if isinstance(metadata, dict):
agent = metadata.get("source_agent")
logger.warning(f"Skipping task persistence for invalid pillar_id={pillar_id!r} "
f"from agent {agent or 'unknown'}: title={t.get('title', '')}")
continue
task = DailyWorkflowTask(
plan_id=plan.id,

View File

@@ -225,9 +225,9 @@ class WixService:
'error': str(e)
}
def import_image_to_wix(self, access_token: str, image_url: str, display_name: str = None) -> str:
def import_image_to_wix(self, access_token: str, image_url: str, display_name: str = None) -> Optional[str]:
"""
Import external image to Wix Media Manager
Import external image to Wix Media Manager.
Args:
access_token: Valid access token
@@ -235,7 +235,7 @@ class WixService:
display_name: Optional display name for the image
Returns:
Wix media ID
Wix media ID string, or None if import failed
"""
try:
result = self.media_service.import_image(
@@ -243,10 +243,15 @@ class WixService:
image_url,
display_name or f'Imported Image {datetime.now().strftime("%Y%m%d_%H%M%S")}'
)
return result['file']['id']
except requests.RequestException as e:
logger.error(f"Failed to import image to Wix: {e}")
raise
if result and isinstance(result, dict) and 'file' in result:
media_id = result['file'].get('id')
if media_id:
return str(media_id)
logger.warning(f"Image import returned unexpected result structure: {type(result)}")
return None
except Exception as e:
logger.warning(f"Failed to import image to Wix (non-fatal): {e}")
return None
def convert_content_to_ricos(self, content: str, images: List[str] = None,
use_wix_api: bool = False, access_token: str = None) -> Dict[str, Any]:
@@ -276,7 +281,8 @@ class WixService:
def create_blog_post(self, access_token: str, title: str, content: str,
cover_image_url: str = None, category_ids: List[str] = None,
tag_ids: List[str] = None, publish: bool = True,
member_id: str = None, seo_metadata: Dict[str, Any] = None) -> Dict[str, Any]:
member_id: str = None, seo_metadata: Dict[str, Any] = None,
site_id: str = None) -> Dict[str, Any]:
"""
Create and optionally publish a blog post on Wix
@@ -322,6 +328,7 @@ class WixService:
tag_ids=tag_ids,
publish=publish,
seo_metadata=seo_metadata,
site_id=site_id,
import_image_func=self.import_image_to_wix,
lookup_categories_func=self.lookup_or_create_categories,
lookup_tags_func=self.lookup_or_create_tags,

View File

@@ -9,7 +9,7 @@ if str(ROOT) not in sys.path:
from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor, SemanticHealthMetric
from services.today_workflow_service import _ensure_pillar_coverage, PILLAR_IDS, validate_plan_contextuality
from services.intelligence.sif_agents import ContentGuardianAgent as SifGuardian
from services.intelligence.agents.specialized import ContentGuardianAgent as SifGuardian
from services.intelligence.agents.specialized_agents import ContentGuardianAgent as SpecializedGuardian

View File

@@ -214,6 +214,26 @@ nav:
- Troubleshooting: user-journeys/enterprise/troubleshooting.md
- Advanced Security: user-journeys/enterprise/advanced-security.md
- Features:
- Today's Workflow:
- Overview: features/todays-workflow/overview.md
- User Guide: features/todays-workflow/workflow-guide.md
- Technical Architecture: features/todays-workflow/technical-architecture.md
- API Reference: features/todays-workflow/api-reference.md
- SIF & AI Agents:
- Overview: features/sif-agents/overview.md
- Agent Directory: features/sif-agents/agent-directory.md
- Committee System: features/sif-agents/committee-system.md
- ContentGuardianAgent: features/sif-agents/content-guardian.md
- Team Activity:
- Overview: features/team-activity/overview.md
- Quality Audit Panel: features/team-activity/quality-audit.md
- Trend Signals Panel: features/team-activity/trend-signals.md
- Alert System: features/team-activity/alert-system.md
- Onboarding System:
- Overview: features/onboarding/overview.md
- Onboarding Steps: features/onboarding/steps.md
- Scheduled Tasks: features/onboarding/scheduler-tasks.md
- Technical Reference: features/onboarding/technical-reference.md
- Backlink Outreach:
- Overview: features/backlink-outreach/overview.md
- Workflow Guide: features/backlink-outreach/workflow-guide.md

View File

@@ -45,6 +45,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/marked": "^5.0.2",
"@types/node": "^25.0.10",
"source-map-explorer": "^2.5.2",
"typescript": "^5.3.3"
@@ -7236,6 +7237,13 @@
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -12064,15 +12072,6 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
@@ -28629,6 +28628,15 @@
"node": ">=8"
}
},
"node_modules/uvu/node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/uvu/node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",

View File

@@ -68,6 +68,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/marked": "^5.0.2",
"@types/node": "^25.0.10",
"source-map-explorer": "^2.5.2",
"typescript": "^5.3.3"

View File

@@ -193,10 +193,10 @@ const App: React.FC = () => {
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
<Route path="/seo" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/seo-dashboard" element={<ProtectedRoute><FeatureRoute feature="seo"><SEODashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/backlink-outreach" element={<ProtectedRoute><FeatureRoute feature="seo"><BacklinkOutreachDashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/backlink-outreach" element={<ProtectedRoute><FeatureRoute feature="backlinking"><BacklinkOutreachDashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/content-planning" element={<ProtectedRoute><FeatureRoute feature="content-planning"><ContentPlanningDashboard /></FeatureRoute></ProtectedRoute>} />
<Route path="/facebook-writer" element={<ProtectedRoute><FeatureRoute feature="social"><FacebookWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/linkedin-writer" element={<ProtectedRoute><FeatureRoute feature="social"><LinkedInWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/facebook-writer" element={<ProtectedRoute><FeatureRoute feature="facebook"><FacebookWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/linkedin-writer" element={<ProtectedRoute><FeatureRoute feature="linkedin"><LinkedInWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><FeatureRoute feature="blog_writer"><BlogWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><FeatureRoute feature="story"><StoryWriter /></FeatureRoute></ProtectedRoute>} />
<Route path="/story-projects" element={<ProtectedRoute><FeatureRoute feature="story"><StoryProjectList /></FeatureRoute></ProtectedRoute>} />

View File

@@ -76,6 +76,20 @@ export interface DeepDiscoveryResponse {
// -- Policy --
export interface SenderIdentity {
name: string;
email: string;
organization: string;
physical_mailing_address: string;
reply_to_email?: string;
}
export interface OneClickUnsubscribe {
enabled: boolean;
mailto?: string;
header_value?: string;
}
export interface BacklinkPolicyValidationRequest {
user_id: string;
workspace_id: string;
@@ -83,10 +97,15 @@ export interface BacklinkPolicyValidationRequest {
recipient_email: string;
recipient_domain: string;
recipient_region: string;
recipient_region_source: string;
legal_basis: string;
contact_discovery_source: string;
consent_status: string;
approved_by_human: boolean;
unsubscribe_url?: string;
sender_identity: string;
one_click_unsubscribe?: OneClickUnsubscribe;
sender_identity: SenderIdentity;
sender_email?: string;
idempotency_key: string;
}
@@ -139,7 +158,7 @@ export interface LeadRecord {
email: string | null;
confidence_score: number;
discovery_source: string;
status: string;
status: LeadStatus;
notes: string | null;
created_at: string | null;
}
@@ -160,9 +179,12 @@ export interface LeadCreateRequest {
notes?: string;
}
export type LeadStatus = 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed';
export interface LeadStatusUpdateRequest {
status: string;
status: LeadStatus;
notes?: string;
campaign_id?: string;
}
export interface CampaignDetailResponse {
@@ -183,6 +205,15 @@ export interface SendOutreachRequest {
subject: string;
body: string;
idempotency_key: string;
sender_identity: SenderIdentity;
legal_basis: string;
contact_discovery_source: string;
recipient_region: string;
recipient_region_source: string;
consent_status: string;
approved_by_human: boolean;
unsubscribe_url?: string;
one_click_unsubscribe?: OneClickUnsubscribe;
template_id?: string;
template_variables?: Record<string, string>;
}
@@ -192,6 +223,7 @@ export interface SendOutreachResponse {
status: string;
policy_allowed: boolean;
policy_reasons: string[];
effective_sender_email?: string | null;
}
export interface OutreachAttemptRecord {
@@ -305,8 +337,9 @@ export interface FollowUpRequest {
export interface BulkStatusUpdateRequest {
lead_ids: string[];
status: string;
status: LeadStatus;
notes?: string;
campaign_id?: string;
}
export interface BulkStatusUpdateResponse {

View File

@@ -12,6 +12,7 @@ import {
GenerateEmailRequest,
bulkUpdateLeadStatus,
updateLeadStatus,
addLeadToCampaign,
fetchCampaignAnalyticsVolume,
fetchCampaignAnalyticsFunnel,
CampaignVolumePoint,
@@ -25,7 +26,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as
type Tab = 'campaigns' | 'discover' | 'leads' | 'composer' | 'analytics';
const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed'];
const STATUS_OPTIONS = ['discovered', 'contacted', 'replied', 'placed', 'bounced', 'unsubscribed'] as const;
const STATUS_EXPLANATIONS: Record<string, string> = {
discovered: 'Lead found but not yet contacted',
@@ -116,6 +117,19 @@ const BacklinkOutreachDashboard: React.FC = () => {
const [subjectSuggestions, setSubjectSuggestions] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [senderName, setSenderName] = useState('');
const [senderEmail, setSenderEmail] = useState('');
const [senderOrganization, setSenderOrganization] = useState('');
const [senderAddress, setSenderAddress] = useState('');
const [unsubscribeUrl, setUnsubscribeUrl] = useState('');
const [oneClickUnsubscribe, setOneClickUnsubscribe] = useState(false);
const [legalBasis, setLegalBasis] = useState('legitimate_interest');
const [contactDiscoverySource, setContactDiscoverySource] = useState('');
const [recipientRegion, setRecipientRegion] = useState('unknown');
const [recipientRegionSource, setRecipientRegionSource] = useState('user_attested');
const [consentStatus, setConsentStatus] = useState('unknown');
const [approvedByHuman, setApprovedByHuman] = useState(false);
const [leadName, setLeadName] = useState('');
const [leadSite, setLeadSite] = useState('');
const [leadContentTopic, setLeadContentTopic] = useState('');
@@ -126,7 +140,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
const [templateName, setTemplateName] = useState('');
const [selectedLeadIds, setSelectedLeadIds] = useState<Set<string>>(new Set());
const [bulkStatus, setBulkStatus] = useState('contacted');
const [bulkStatus, setBulkStatus] = useState<'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed'>('contacted');
const [volumeData, setVolumeData] = useState<CampaignVolumePoint[]>([]);
const [funnelData, setFunnelData] = useState<FunnelStage[]>([]);
@@ -190,9 +204,24 @@ const BacklinkOutreachDashboard: React.FC = () => {
}, [keyword, deepDiscover]);
const handleDiscoverAndSave = useCallback(async () => {
if (!keyword.trim() || !discoverCampaignId) return;
await deepDiscover(keyword.trim(), 15, discoverCampaignId);
}, [keyword, discoverCampaignId, deepDiscover]);
if (!keyword.trim() || !discoverCampaignId || discoveredOpportunities.length === 0) return;
for (const opp of discoveredOpportunities) {
try {
await addLeadToCampaign(discoverCampaignId, {
campaign_id: discoverCampaignId,
url: opp.url,
domain: opp.domain,
page_title: opp.page_title,
snippet: opp.snippet,
email: opp.email ?? undefined,
confidence_score: opp.confidence_score,
});
} catch (e) {
// skip duplicates
}
}
showToastNotification(`Saved ${discoveredOpportunities.length} leads to campaign`, 'success');
}, [keyword, discoverCampaignId, discoveredOpportunities]);
const handleSelectCampaign = useCallback(async (campaignId: string) => {
await selectCampaign(campaignId);
@@ -311,10 +340,13 @@ const BacklinkOutreachDashboard: React.FC = () => {
);
};
const handleSingleStatusUpdate = async (leadId: string, status: string) => {
const handleSingleStatusUpdate = async (leadId: string, status: 'discovered' | 'contacted' | 'replied' | 'placed' | 'bounced' | 'unsubscribed') => {
setIsStatusUpdating(true);
try {
await updateLeadStatus(leadId, { status });
await updateLeadStatus(leadId, {
status,
campaign_id: selectedCampaign!.campaign_id,
});
showToastNotification(`Status updated to "${status}"`, 'success');
await selectCampaign(selectedCampaign!.campaign_id);
} catch (e) {
@@ -328,7 +360,11 @@ const BacklinkOutreachDashboard: React.FC = () => {
if (selectedLeadIds.size === 0) return;
setIsStatusUpdating(true);
try {
const result = await bulkUpdateLeadStatus({ lead_ids: Array.from(selectedLeadIds), status: bulkStatus });
const result = await bulkUpdateLeadStatus({
lead_ids: Array.from(selectedLeadIds),
status: bulkStatus,
campaign_id: selectedCampaign!.campaign_id,
});
if (result.failed.length > 0) {
showToastNotification(`Updated ${result.updated} leads; ${result.failed.length} failed`, 'warning');
} else {
@@ -391,10 +427,27 @@ const BacklinkOutreachDashboard: React.FC = () => {
{ key: 'campaigns', label: 'Campaigns', desc: 'Create and manage outreach campaigns' },
{ key: 'discover', label: 'Discover', desc: 'AI-powered search for guest post opportunities' },
{ key: 'leads', label: 'Leads', desc: 'Track leads, send outreach, and manage replies' },
{ key: 'composer', label: 'Composer', desc: 'AI email composer with smart suggestions' },
{ key: 'composer', label: 'Composer', desc: 'AI email composer with compliance metadata' },
{ key: 'analytics', label: 'Analytics', desc: 'Campaign performance metrics and exports' },
];
const complianceReasons = [
!unsubscribeUrl.trim() && !oneClickUnsubscribe ? 'Add an unsubscribe URL or enable one-click unsubscribe.' : '',
!senderName.trim() ? 'Add the sender name.' : '',
!senderEmail.trim() ? 'Add the sender email.' : '',
!senderOrganization.trim() ? 'Add the sender organization.' : '',
!senderAddress.trim() ? 'Add a physical mailing address.' : '',
!legalBasis.trim() ? 'Record the legal basis.' : '',
!contactDiscoverySource.trim() ? 'Record where the contact was discovered.' : '',
recipientRegion === 'unknown' && !approvedByHuman ? 'Unknown recipient region requires manual review.' : '',
recipientRegionSource === 'tld_inference' && !approvedByHuman ? 'TLD-only region inference requires manual review.' : '',
['eu', 'eea', 'uk', 'ca'].includes(recipientRegion) && (legalBasis !== 'consent' || consentStatus !== 'explicit')
? 'Selected recipient region requires recorded explicit consent.' : '',
].filter(Boolean);
const complianceReady = complianceReasons.length === 0;
const SectionHeader: React.FC<{ title: string; subtitle: string }> = ({ title, subtitle }) => (
<div style={{ marginBottom: '16px' }}>
<h3 style={{ margin: 0, background: GRADIENT_PRIMARY, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', fontSize: '18px' }}>{title}</h3>
@@ -644,7 +697,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
{selectedLeadIds.size > 0 && (
<>
<TooltipWrap text="Choose the new status for all selected leads">
<select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value)}
<select value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value as typeof bulkStatus)}
style={{ ...selectSx, padding: '6px 10px', fontSize: '12px', minWidth: '130px' }}>
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
@@ -708,6 +761,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
<div key={a.attempt_id} style={{ marginTop: '8px', padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: '8px', fontSize: '12px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)' }}>Latest: {a.subject} </span>
{renderStatusBadge(a.status)}
{a.sender_email && <span style={{ color: 'rgba(255,255,255,0.35)', marginLeft: '8px' }}>From: {a.sender_email}</span>}
{a.sent_at && <span style={{ color: 'rgba(255,255,255,0.3)', marginLeft: '8px' }}>{new Date(a.sent_at).toLocaleString()}</span>}
</div>
))}
@@ -724,7 +778,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ background: 'rgba(255,255,255,0.04)' }}>
{['Subject', 'Status', 'Sender', 'Sent At'].map(h => (
{['Subject', 'Status', 'Effective Sender', 'Sent At'].map(h => (
<th key={h} style={{ padding: '10px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)', textAlign: 'left', color: 'rgba(255,255,255,0.4)', fontWeight: 500, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{h}</th>
))}
</tr>
@@ -893,6 +947,71 @@ const BacklinkOutreachDashboard: React.FC = () => {
style={{ ...inputSx, fontFamily: 'monospace', fontSize: '13px', resize: 'vertical', lineHeight: 1.6 }} />
</div>
{/* Compliance metadata */}
<div style={{ marginTop: '20px', padding: '16px', borderRadius: '10px', background: complianceReady ? 'rgba(67,233,123,0.08)' : 'rgba(245,87,108,0.08)', border: `1px solid ${complianceReady ? 'rgba(67,233,123,0.22)' : 'rgba(245,87,108,0.22)'}` }}>
<h4 style={{ margin: '0 0 4px', color: '#fff', fontSize: '14px' }}>Send Compliance Metadata</h4>
<p style={{ margin: '0 0 12px', color: 'rgba(255,255,255,0.45)', fontSize: '12px' }}>Policy checks require unsubscribe, sender identity, legal basis, contact source, and region-aware consent/review details before a send can be approved.</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<input type="text" value={senderName} onChange={(e) => setSenderName(e.target.value)} placeholder="Sender name" style={inputSx} />
<input type="email" value={senderEmail} onChange={(e) => setSenderEmail(e.target.value)} placeholder="Sender email" style={inputSx} />
<input type="text" value={senderOrganization} onChange={(e) => setSenderOrganization(e.target.value)} placeholder="Organization / brand" style={inputSx} />
<input type="text" value={senderAddress} onChange={(e) => setSenderAddress(e.target.value)} placeholder="Physical mailing address" style={inputSx} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<input type="url" value={unsubscribeUrl} onChange={(e) => setUnsubscribeUrl(e.target.value)} placeholder="Unsubscribe URL" style={inputSx} />
<label style={{ ...inputSx, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={oneClickUnsubscribe} onChange={(e) => setOneClickUnsubscribe(e.target.checked)} />
One-click unsubscribe available
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<select value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} style={selectSx}>
<option value="legitimate_interest">Legitimate interest</option>
<option value="consent">Consent</option>
<option value="contract">Contract</option>
</select>
<input type="text" value={contactDiscoverySource} onChange={(e) => setContactDiscoverySource(e.target.value)} placeholder="Contact discovery source (e.g. contact page URL)" style={inputSx} />
<select value={recipientRegion} onChange={(e) => setRecipientRegion(e.target.value)} style={selectSx}>
<option value="unknown">Recipient region unknown</option>
<option value="us">United States</option>
<option value="eu">EU / EEA</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
<option value="br">Brazil</option>
<option value="other">Other</option>
</select>
<select value={recipientRegionSource} onChange={(e) => setRecipientRegionSource(e.target.value)} style={selectSx}>
<option value="user_attested">Region user-attested</option>
<option value="crm_record">Region from CRM/contact record</option>
<option value="billing_or_profile">Region from profile/billing data</option>
<option value="tld_inference">Region inferred from TLD only</option>
<option value="unknown">Region source unknown</option>
</select>
<select value={consentStatus} onChange={(e) => setConsentStatus(e.target.value)} style={selectSx}>
<option value="unknown">Consent status unknown</option>
<option value="explicit">Explicit consent recorded</option>
<option value="implied">Implied consent / soft opt-in</option>
<option value="not_required">Not required for selected basis</option>
</select>
<label style={{ ...inputSx, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={approvedByHuman} onChange={(e) => setApprovedByHuman(e.target.checked)} />
Manual review approved
</label>
</div>
<div style={{ padding: '10px 12px', borderRadius: '8px', background: complianceReady ? 'rgba(67,233,123,0.12)' : 'rgba(245,87,108,0.12)', color: complianceReady ? '#43e97b' : '#f5576c', fontSize: '12px' }}>
{complianceReady ? 'Compliance metadata is complete for policy validation.' : (
<ul style={{ margin: 0, paddingLeft: '18px' }}>
{complianceReasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
)}
</div>
</div>
{/* Personalize */}
<div style={{ marginTop: '24px', padding: '16px', borderRadius: '10px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
<h4 style={{ margin: '0 0 4px', color: '#fff', fontSize: '14px' }}>Personalize for Lead</h4>
@@ -946,13 +1065,13 @@ const BacklinkOutreachDashboard: React.FC = () => {
</div>
{selectedCampaign && subject.trim() && body.trim() && (
<div style={{ marginTop: '16px', padding: '14px', borderRadius: '10px', background: 'rgba(67,233,123,0.1)', border: '1px solid rgba(67,233,123,0.2)' }}>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#43e97b' }}>
Ready to send this email to leads in <strong>{selectedCampaign.name}</strong>?
<div style={{ marginTop: '16px', padding: '14px', borderRadius: '10px', background: complianceReady ? 'rgba(67,233,123,0.1)' : 'rgba(245,87,108,0.1)', border: `1px solid ${complianceReady ? 'rgba(67,233,123,0.2)' : 'rgba(245,87,108,0.2)'}` }}>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: complianceReady ? '#43e97b' : '#f5576c' }}>
{complianceReady ? <>Ready to send this email to leads in <strong>{selectedCampaign.name}</strong>.</> : <>Complete compliance metadata before sending to <strong>{selectedCampaign.name}</strong> leads.</>}
</p>
<TooltipWrap text="Go to the Leads tab to select recipients and send">
<button onClick={() => setActiveTab('leads')}
style={{ ...btnBase, padding: '8px 20px', background: GRADIENT_SUCCESS, color: '#1a1a2e', fontSize: '13px' }}>
<TooltipWrap text={complianceReady ? 'Go to the Leads tab to select recipients and send' : 'Policy validation will block sends until all listed compliance fields are complete'}>
<button onClick={() => setActiveTab('leads')} disabled={!complianceReady}
style={{ ...btnBase, padding: '8px 20px', background: GRADIENT_SUCCESS, color: '#1a1a2e', fontSize: '13px', opacity: complianceReady ? 1 : 0.5 }}>
Go to Campaign Leads
</button>
</TooltipWrap>

View File

@@ -111,12 +111,7 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
}}
>
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1.125rem',
lineHeight: 1.8,
color: '#475569',
}}
className="rendered-content-intro"
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(introduction) }}
/>
</Box>
@@ -151,12 +146,7 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
{/* Section Content */}
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1rem',
lineHeight: 1.8,
color: '#334155',
}}
className="rendered-content"
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(section.content) }}
/>
</Box>
@@ -189,15 +179,150 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
</Box>
</Dialog>
{/* Print Styles */}
{/* Rendered Content Styles + Print Styles */}
<style>{`
.rendered-content {
font-family: Georgia, serif;
font-size: 1rem;
line-height: 1.8;
color: #334155;
}
.rendered-content h1, .rendered-content h2, .rendered-content h3 {
color: #111827;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.rendered-content h1 { font-size: 2rem; font-weight: 700; }
.rendered-content h2 { font-size: 1.5rem; font-weight: 600; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; }
.rendered-content h3 { font-size: 1.25rem; font-weight: 600; }
.rendered-content h4 { font-size: 1.15rem; font-weight: 600; color: #1e293b; margin-top: 0.5rem; margin-bottom: 0.25rem; }
.rendered-content h5, .rendered-content h6 { font-size: 1rem; font-weight: 600; color: #334155; margin-top: 0.5rem; margin-bottom: 0.25rem; }
.rendered-content p { margin-bottom: 0.75rem; }
.rendered-content strong { font-weight: 600; }
.rendered-content em { font-style: italic; }
.rendered-content a { color: #4f46e5; text-decoration: underline; }
.rendered-content blockquote {
border-left: 4px solid #e5e7eb;
padding: 0.5rem 1rem;
margin: 0.75rem 0;
color: #6b7280;
font-style: italic;
background: #f9fafb;
}
.rendered-content code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.rendered-content kbd {
background: #f1f5f9;
border: 1px solid #d1d5db;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
font-size: 0.85em;
box-shadow: 0 1px 0 #d1d5db;
}
.rendered-content mark { background: #fef3c7; color: #92400e; padding: 0 4px; border-radius: 2px; }
.rendered-content sub, .rendered-content sup { font-size: 0.75em; line-height: 1; }
.rendered-content details { margin-bottom: 0.75rem; }
.rendered-content details summary { cursor: pointer; font-weight: 600; color: #1e293b; }
.rendered-content details summary:hover { color: #4f46e5; }
.rendered-content dl { margin-bottom: 0.75rem; }
.rendered-content dl dt { font-weight: 600; color: #1e293b; margin-top: 0.5rem; }
.rendered-content dl dd { margin-left: 1rem; color: #4b5563; }
.rendered-content abbr { cursor: help; text-decoration: underline dotted #94a3b8; }
.rendered-content ul, .rendered-content ol {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.rendered-content li { margin-bottom: 0.25rem; }
.rendered-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.5rem 0; }
.rendered-content img { max-width: 100%; height: auto; border-radius: 8px; }
.rendered-content .table-wrapper { overflow-x: auto; margin-bottom: 1rem; }
.rendered-content .table-wrapper table { margin-bottom: 0; }
.rendered-content table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.rendered-content th, .rendered-content td {
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
text-align: left;
}
.rendered-content th { background: #f3f4f6; font-weight: 600; }
.rendered-content tr:nth-of-type(even) { background: #f9fafb; }
.rendered-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: 1rem 0;
}
.rendered-content pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: inherit;
line-height: inherit;
}
.rendered-content del { color: #991b1b; text-decoration: line-through; }
.rendered-content input[type="checkbox"] { margin-right: 0.5rem; transform: scale(1.1); accent-color: #4f46e5; }
.rendered-content-intro {
font-family: Georgia, serif;
font-size: 1.125rem;
line-height: 1.8;
color: #475569;
}
.rendered-content-intro .table-wrapper { overflow-x: auto; margin-bottom: 1rem; }
.rendered-content-intro .table-wrapper table { margin-bottom: 0; }
.rendered-content-intro table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.rendered-content-intro th, .rendered-content-intro td {
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
text-align: left;
}
.rendered-content-intro th { background: #f3f4f6; font-weight: 600; }
.rendered-content-intro tr:nth-of-type(even) { background: #f9fafb; }
.rendered-content-intro pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; font-family: monospace; font-size: 0.875rem; line-height: 1.5; margin: 1rem 0; }
.rendered-content-intro pre code { background: transparent; color: inherit; padding: 0; font-size: inherit; line-height: inherit; }
.rendered-content-intro code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.9em; }
.rendered-content-intro a { color: #4f46e5; text-decoration: underline; }
.rendered-content-intro blockquote {
border-left: 4px solid #e5e7eb;
padding: 0.5rem 1rem;
margin: 0.75rem 0;
color: #6b7280;
font-style: italic;
background: #f9fafb;
}
.rendered-content-intro ul, .rendered-content-intro ol { padding-left: 1.5rem; margin-bottom: 0.75rem; }
.rendered-content-intro li { margin-bottom: 0.25rem; }
.rendered-content-intro img { max-width: 100%; height: auto; border-radius: 8px; }
.rendered-content-intro del { color: #991b1b; text-decoration: line-through; }
.rendered-content-intro input[type="checkbox"] { margin-right: 0.5rem; transform: scale(1.1); accent-color: #4f46e5; }
.rendered-content-intro h1, .rendered-content-intro h2, .rendered-content-intro h3, .rendered-content-intro h4, .rendered-content-intro h5, .rendered-content-intro h6 { color: #111827; }
@media print {
body * {
visibility: hidden;
}
.MuiDialogContent-root, .MuiDialogContent-root * {
visibility: visible;
}
body * { visibility: hidden; }
.MuiDialogContent-root, .MuiDialogContent-root * { visibility: visible; }
.MuiDialogContent-root {
position: absolute;
left: 0;
@@ -207,19 +332,11 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
margin: 0 !important;
padding: 20px !important;
}
/* Hide UI elements */
.MuiDialog-paper > div:first-child,
.MuiDialog-paper > div:last-child {
display: none !important;
}
/* Optimize for print */
h1, h2, h3 {
page-break-after: avoid;
}
img {
max-width: 100% !important;
page-break-inside: avoid;
}
.MuiDialog-paper > div:last-child { display: none !important; }
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
img { max-width: 100% !important; page-break-inside: avoid; }
pre, table { page-break-inside: avoid; }
}
`}</style>
</>

View File

@@ -21,6 +21,7 @@ import { SEOProcessor } from './SEO';
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
import { SEOAnalysisModal } from './SEOAnalysisModal';
import { SEOMetadataModal } from './SEOMetadataModal';
import { DiffPreviewModal } from './DiffPreviewModal/DiffPreviewModal';
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
import HeaderBar from './BlogWriterUtils/HeaderBar';
import PhaseContent from './BlogWriterUtils/PhaseContent';
@@ -65,6 +66,7 @@ const BlogWriter: React.FC = () => {
titleOptions,
selectedTitle,
sections,
introduction,
seoAnalysis,
seoMetadata,
continuityRefresh,
@@ -84,6 +86,7 @@ const BlogWriter: React.FC = () => {
setTitleOptions,
setSelectedTitle,
setSections,
setIntroduction,
setSeoAnalysis,
setSeoMetadata,
setContinuityRefresh,
@@ -134,8 +137,13 @@ const BlogWriter: React.FC = () => {
handleSEOAnalysisComplete,
handleSEOModalClose,
confirmBlogContent,
isDiffModalOpen,
diffPreviewData,
acceptDiffChanges,
rejectDiffChanges,
} = useSEOManager({
sections,
introduction,
research,
outline,
selectedTitle,
@@ -148,6 +156,7 @@ const BlogWriter: React.FC = () => {
setSeoMetadata,
setSections,
setSelectedTitle: setSelectedTitle as (title: string | null) => void,
setIntroduction,
setContinuityRefresh,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
@@ -497,6 +506,7 @@ const BlogWriter: React.FC = () => {
localStorage.removeItem('blogwriter_user_selected_phase');
localStorage.removeItem('blog_content_confirmed');
localStorage.removeItem('blog_seo_recommendations_applied');
localStorage.removeItem('blog_publish_completed');
localStorage.removeItem('blog_last_asset_id');
} catch {
// ignore localStorage errors
@@ -725,7 +735,7 @@ const BlogWriter: React.FC = () => {
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
hasSEOAnalysis={!!seoAnalysis && (seoRecommendationsApplied || !!seoMetadata)}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
onNewBlog={confirmNewBlog}
@@ -764,6 +774,8 @@ const BlogWriter: React.FC = () => {
researchCoverage={researchCoverage}
setOutline={setOutline}
sections={sections}
introduction={introduction}
onIntroductionUpdate={setIntroduction}
handleContentUpdate={handleContentUpdate}
handleContentSave={handleContentSave}
continuityRefresh={continuityRefresh}
@@ -827,6 +839,14 @@ const BlogWriter: React.FC = () => {
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
/>
{/* Diff Preview Modal */}
<DiffPreviewModal
isOpen={isDiffModalOpen}
diffData={diffPreviewData}
onAccept={acceptDiffChanges}
onReject={rejectDiffChanges}
/>
{/* SEO Metadata Modal */}
<SEOMetadataModal
isOpen={isSEOMetadataModalOpen}

View File

@@ -50,6 +50,8 @@ interface PhaseContentProps {
onAngleSelect?: (angle: string) => void;
selectedCompetitiveAdvantage?: string;
onCompetitiveAdvantageSelect?: (advantage: string) => void;
introduction?: string;
onIntroductionUpdate?: (intro: string) => void;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
@@ -95,6 +97,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onAngleSelect,
selectedCompetitiveAdvantage,
onCompetitiveAdvantageSelect,
introduction,
onIntroductionUpdate,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -173,8 +177,10 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
introduction={introduction}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
onIntroductionUpdate={onIntroductionUpdate}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
sectionImages={sectionImages}
@@ -199,7 +205,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
)}
</>
)}
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
<>
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
@@ -211,8 +217,10 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
introduction={introduction}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
onIntroductionUpdate={onIntroductionUpdate}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
sectionImages={sectionImages}

View File

@@ -4,6 +4,7 @@ import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../a
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
import WixConnectModal from './WixConnectModal';
import { useWixPublish } from '../../../hooks/useWixPublish';
import { useTextToSpeech } from '../../../hooks/useTextToSpeech';
const saveCompleteBlogAsset = async (
title: string,
@@ -48,6 +49,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
setShowWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
validateWixContent,
} = useWixPublish();
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
@@ -55,6 +57,39 @@ export const PublishContent: React.FC<PublishContentProps> = ({
const [publishing, setPublishing] = useState<string | null>(null);
const [publishResult, setPublishResult] = useState<{ platform: string; success: boolean; message: string; url?: string } | null>(null);
const [copyDone, setCopyDone] = useState(false);
const [wixContentWarning, setWixContentWarning] = useState<string | null>(null);
// Audio / TTS
const { speak, stop, isSpeaking, isSupported } = useTextToSpeech();
const [isListening, setIsListening] = useState(false);
const stripMarkdown = (md: string) => {
return md
.replace(/[#*_~`]/g, '')
.replace(/\[(.*?)\]\(.*\)/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\n{2,}/g, '\n')
.trim();
};
const handleListen = () => {
if (isSpeaking) {
stop();
setIsListening(false);
return;
}
const md = buildFullMarkdown();
const plainText = stripMarkdown(md);
if (!plainText) return;
setIsListening(true);
speak(plainText, { rate: 1 });
};
useEffect(() => {
if (isListening && !isSpeaking) {
setIsListening(false);
}
}, [isSpeaking, isListening]);
useEffect(() => {
checkWPStatus();
@@ -105,6 +140,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
const result = await wordpressAPI.publishContent(request);
if (result.success) {
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
} else {
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
}
@@ -118,10 +154,24 @@ export const PublishContent: React.FC<PublishContentProps> = ({
const handlePublishToWix = async () => {
const md = buildFullMarkdown();
setPublishResult(null);
setWixContentWarning(null);
const validation = validateWixContent(md);
if (!validation.valid) {
setPublishResult({ platform: 'wix', success: false, message: validation.warning || 'Content validation failed.' });
return;
}
if (validation.warning) {
setWixContentWarning(validation.warning);
}
const result = await publishToWix(md, seoMetadata, blogTitle);
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.warning && result.success) {
setWixContentWarning(result.warning);
}
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.success) {
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
}
};
@@ -227,6 +277,11 @@ export const PublishContent: React.FC<PublishContentProps> = ({
Site: {wixStatus.site_info.name || wixStatus.site_info.displayName}
</div>
)}
{wixContentWarning && (
<div style={{ marginTop: 8, padding: '6px 10px', fontSize: '0.8rem', color: '#92400e', background: '#fef3c7', borderRadius: 6, border: '1px solid #fcd34d' }}>
{wixContentWarning}
</div>
)}
</div>
{/* Export card */}
@@ -235,7 +290,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
<p style={{ margin: '4px 0 12px 0', fontSize: '0.85rem', color: '#64748b' }}>
Copy your blog content for use elsewhere
</p>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={handleCopyMarkdown}
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
@@ -248,6 +303,19 @@ export const PublishContent: React.FC<PublishContentProps> = ({
>
{copyDone ? 'Copied!' : 'Copy HTML'}
</button>
{isSupported && (
<button
onClick={handleListen}
style={{
...btnStyle,
background: isListening ? '#fef2f2' : '#f1f5f9',
color: isListening ? '#991b1b' : '#334155',
border: `1px solid ${isListening ? '#fecaca' : '#e2e8f0'}`,
}}
>
{isListening ? '🔊 Stop Listening' : '🔈 Listen to Blog'}
</button>
)}
</div>
</div>
</div>

View File

@@ -11,6 +11,8 @@ import {
Alert
} from '@mui/material';
import { usePlatformConnections } from '../../../components/OnboardingWizard/common/usePlatformConnections';
import { getWixTrustedOrigins } from '../../../config/wixConfig';
import { markConnectionHandled, isAlreadyHandled } from '../../../utils/wixConnectionDedup';
interface WixConnectModalProps {
isOpen: boolean;
@@ -32,12 +34,13 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
if (!isOpen) return;
const handler = (event: MessageEvent) => {
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const trusted = [window.location.origin, ngrokOrigin];
const trusted = getWixTrustedOrigins();
if (!trusted.includes(event.origin)) return;
if (!event.data || typeof event.data !== 'object') return;
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
if (isAlreadyHandled()) return;
markConnectionHandled();
console.log('Wix OAuth success in modal');
setIsConnecting(false);
setError(null);
@@ -65,6 +68,8 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const params = new URLSearchParams(window.location.search);
if (params.get('wix_connected') === 'true') {
if (isAlreadyHandled()) return;
markConnectionHandled();
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {
@@ -81,6 +86,8 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const handler = (e: StorageEvent) => {
if (e.key === 'wix_connected' && e.newValue === 'true') {
if (isAlreadyHandled()) return;
markConnectionHandled();
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {

View File

@@ -42,11 +42,15 @@ export const usePhaseRestoration = ({
const restoredPhase = localStorage.getItem('blogwriter_current_phase');
const userSelectedPhase = localStorage.getItem('blogwriter_user_selected_phase') === 'true';
// Determine if we should restore based on user selection OR publish completion
const publishCompleted = localStorage.getItem('blog_publish_completed') === 'true';
const shouldRestore = restoredPhase && restoredPhase !== currentPhase && (userSelectedPhase || publishCompleted);
// Only restore if:
// 1. A phase was saved (restoredPhase exists)
// 2. User had manually selected a phase (indicates they were actively working)
// 2. User had manually selected a phase OR publish was completed (indicates they were actively working)
// 3. The phase is different from current (to avoid unnecessary updates)
if (restoredPhase && userSelectedPhase && restoredPhase !== currentPhase) {
if (shouldRestore) {
const targetPhase = phases.find(p => p.id === restoredPhase);
if (targetPhase && !targetPhase.disabled) {
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);

View File

@@ -3,6 +3,7 @@ import { debug } from '../../../utils/debug';
import { hashContent, getSeoCacheKey } from '../../../utils/contentHash';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
import { blogWriterCache } from '../../../services/blogWriterCache';
import { getSectionDiffs, DiffPreviewData } from '../../../utils/getSectionDiffs';
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
if (key === undefined || key === null) {
@@ -179,6 +180,7 @@ const resolveContentForOutlineSection = (
interface UseSEOManagerProps {
sections: Record<string, string>;
introduction?: string;
research: any;
outline: any[];
selectedTitle: string | null;
@@ -191,6 +193,7 @@ interface UseSEOManagerProps {
setSeoMetadata: (metadata: any) => void;
setSections: (sections: Record<string, string>) => void;
setSelectedTitle: (title: string | null) => void;
setIntroduction: (intro: string) => void;
setContinuityRefresh: (timestamp: number) => void;
setFlowAnalysisCompleted: (completed: boolean) => void;
setFlowAnalysisResults: (results: any) => void;
@@ -198,6 +201,7 @@ interface UseSEOManagerProps {
export const useSEOManager = ({
sections,
introduction,
research,
outline,
selectedTitle,
@@ -210,6 +214,7 @@ export const useSEOManager = ({
setSeoMetadata,
setSections,
setSelectedTitle,
setIntroduction,
setContinuityRefresh,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
@@ -219,6 +224,17 @@ export const useSEOManager = ({
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Diff preview state
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const [diffPreviewData, setDiffPreviewData] = useState<DiffPreviewData | null>(null);
const pendingSectionsRef = useRef<Record<string, string> | null>(null);
const pendingSectionsKeysRef = useRef<string[] | null>(null);
const pendingIntroductionRef = useRef<string | null>(null);
const pendingTitleRef = useRef<string | null>(null);
const pendingAppliedRef = useRef<any>(null);
const originalSectionsRef = useRef<Record<string, string> | null>(null);
const originalIntroductionRef = useRef<string | null>(null);
// Restore cached SEO analysis on mount when sections are available
useEffect(() => {
const restoreCachedSEO = async () => {
@@ -322,6 +338,10 @@ export const useSEOManager = ({
throw new Error('An outline is required before applying recommendations.');
}
// Capture originals before API call for diff preview
originalSectionsRef.current = { ...(sections || {}) };
originalIntroductionRef.current = introduction || '';
const existingContentMap = buildExistingContentMap(sections || {});
const emptyMap = new Map<string, string>();
@@ -348,6 +368,7 @@ export const useSEOManager = ({
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
introduction: introduction || undefined,
sections: sectionPayload,
outline,
research: (research as any) || {},
@@ -362,6 +383,13 @@ export const useSEOManager = ({
throw new Error('Recommendation response did not include updated sections.');
}
if (response.sections.length !== outline.length) {
debug.log('[BlogWriter] WARNING: API returned different section count', {
apiCount: response.sections.length,
outlineCount: outline.length,
});
}
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
const normalizedSections: Record<string, string> = {};
@@ -403,48 +431,112 @@ export const useSEOManager = ({
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
setSections(normalizedSections);
debug.log('[BlogWriter] handleApplySeoRecommendations: computed diffs, showing preview', {
keys: Object.keys(normalizedSections),
});
// Store pending changes (don't apply yet)
pendingSectionsRef.current = normalizedSections;
pendingSectionsKeysRef.current = uniqueSectionKeys;
pendingIntroductionRef.current = response.introduction ?? null;
pendingTitleRef.current = response.title ?? null;
pendingAppliedRef.current = response.applied ?? null;
// Build diff data from originals vs pending
const outlineHeadings = outline.map((s: any) => ({ id: getPrimaryKeyForOutlineSection(s, outline.indexOf(s)), heading: s.heading || s.title || `Section ${outline.indexOf(s) + 1}` }));
const diffData = getSectionDiffs(
outlineHeadings,
originalSectionsRef.current,
normalizedSections,
originalIntroductionRef.current || undefined,
response.introduction || undefined
);
setDiffPreviewData(diffData);
setIsDiffModalOpen(true);
// Cache the pending content
try {
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
} catch (cacheError) {
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
}
}, [outline, research, sections, introduction, selectedTitle, setSections]);
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
const acceptDiffChanges = useCallback(() => {
const normalizedSections = pendingSectionsRef.current;
const uniqueSectionKeys = pendingSectionsKeysRef.current;
if (!normalizedSections || !uniqueSectionKeys) {
debug.log('[BlogWriter] acceptDiffChanges: no pending changes to apply');
return;
}
debug.log('[BlogWriter] Accepting diff changes, applying sections', {
keys: Object.keys(normalizedSections),
});
setSections(normalizedSections);
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
if (response.title && response.title !== selectedTitle) {
setSelectedTitle(response.title);
const pendingIntro = pendingIntroductionRef.current;
if (pendingIntro !== null && pendingIntro !== introduction) {
setIntroduction(pendingIntro);
debug.log('[BlogWriter] Introduction updated from SEO response', {
length: pendingIntro.length,
preview: pendingIntro.substring(0, 80),
});
}
if (response.applied) {
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: response.applied } : prev);
const pendingTitle = pendingTitleRef.current;
if (pendingTitle && pendingTitle !== selectedTitle) {
setSelectedTitle(pendingTitle);
}
if (pendingAppliedRef.current) {
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: pendingAppliedRef.current } : prev);
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
}
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
try {
localStorage.setItem('blog_seo_recommendations_applied', 'true');
} catch {}
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
debug.log('[BlogWriter] Forced navigation to SEO phase after accepting changes');
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
// Clean up pending and close
pendingSectionsRef.current = null;
pendingSectionsKeysRef.current = null;
pendingIntroductionRef.current = null;
pendingTitleRef.current = null;
pendingAppliedRef.current = null;
originalSectionsRef.current = null;
originalIntroductionRef.current = null;
setIsDiffModalOpen(false);
setDiffPreviewData(null);
}, [setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setIntroduction, introduction, setSelectedTitle, selectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
const rejectDiffChanges = useCallback(() => {
debug.log('[BlogWriter] Rejecting diff changes, discarding pending content');
// Clean up pending without applying
pendingSectionsRef.current = null;
pendingSectionsKeysRef.current = null;
pendingIntroductionRef.current = null;
pendingTitleRef.current = null;
pendingAppliedRef.current = null;
originalSectionsRef.current = null;
originalIntroductionRef.current = null;
setIsDiffModalOpen(false);
setDiffPreviewData(null);
}, []);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
@@ -518,6 +610,10 @@ export const useSEOManager = ({
handleSEOAnalysisComplete,
handleSEOModalClose,
confirmBlogContent,
isDiffModalOpen,
diffPreviewData,
acceptDiffChanges,
rejectDiffChanges,
};
};

View File

@@ -0,0 +1,164 @@
import React from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, IconButton, Divider
} from '@mui/material';
import { Close as CloseIcon, Check as CheckIcon } from '@mui/icons-material';
import type { DiffPreviewData, DiffSegment } from '../../../utils/getSectionDiffs';
interface DiffPreviewModalProps {
isOpen: boolean;
diffData: DiffPreviewData | null;
onAccept: () => void;
onReject: () => void;
loading?: boolean;
}
function renderDiffSegments(segments: DiffSegment[]): React.ReactNode {
return segments.map((seg, i) => {
if (seg.added) {
return (
<Box
component="span"
key={i}
sx={{
bgcolor: '#dcfce7',
color: '#166534',
px: 0.5,
borderRadius: '3px',
fontWeight: 600,
}}
>
{seg.value}
</Box>
);
}
if (seg.removed) {
return (
<Box
component="span"
key={i}
sx={{
bgcolor: '#fee2e2',
color: '#991b1b',
px: 0.5,
borderRadius: '3px',
textDecoration: 'line-through',
textDecorationColor: '#dc2626',
}}
>
{seg.value}
</Box>
);
}
return <span key={i}>{seg.value}</span>;
});
}
export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
isOpen,
diffData,
onAccept,
onReject,
loading = false,
}) => {
if (!diffData) return null;
const hasAnyChange = diffData.introductionChanged || diffData.sectionDiffs.some(s => s.changed);
return (
<Dialog open={isOpen} maxWidth="lg" fullWidth fullScreen>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 10,
bgcolor: 'white',
borderBottom: '2px solid #e2e8f0',
}}
>
<DialogTitle sx={{ pb: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, flexGrow: 1 }}>
SEO Recommendations Review Changes
</Typography>
<IconButton onClick={onReject} size="small"><CloseIcon /></IconButton>
</DialogTitle>
<Box sx={{ px: 3, pb: 1.5, display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
icon={<CheckIcon />}
label={`${diffData.sectionDiffs.filter(s => s.changed).length} section(s) changed`}
color="warning"
size="small"
variant="outlined"
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', ml: 'auto' }}>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', fontSize: '0.75rem', color: '#166534' }}>
<Box sx={{ width: 14, height: 14, bgcolor: '#dcfce7', border: '1px solid #86efac', borderRadius: '2px' }} />
<span>Added</span>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', fontSize: '0.75rem', color: '#991b1b' }}>
<Box sx={{ width: 14, height: 14, bgcolor: '#fee2e2', border: '1px solid #fca5a5', borderRadius: '2px' }} />
<span>Removed</span>
</Box>
</Box>
</Box>
</Box>
<DialogContent sx={{ py: 3, bgcolor: '#f8fafc' }}>
{!hasAnyChange && (
<Typography sx={{ textAlign: 'center', py: 4, color: '#64748b' }}>
No changes detected between original and recommended content.
</Typography>
)}
{diffData.introductionChanged && (
<Box sx={{ mb: 4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 1 }}>
Introduction
</Typography>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
{renderDiffSegments(diffData.introductionDiff!)}
</Box>
</Box>
)}
{diffData.sectionDiffs.map((section, idx) => {
if (!section.changed) return null;
return (
<Box key={section.heading || idx} sx={{ mb: 3 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 0.5 }}>
{section.heading}
</Typography>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
{renderDiffSegments(section.segments)}
</Box>
</Box>
);
})}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white' }}>
<Button
onClick={onReject}
disabled={loading}
variant="outlined"
color="error"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Reject Changes
</Button>
<Button
onClick={onAccept}
disabled={loading}
variant="contained"
color="primary"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Accept Changes
</Button>
</DialogActions>
</Dialog>
);
};
export default DiffPreviewModal;

View File

@@ -910,16 +910,25 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
defaultPrompt={getSectionHeading(imageModalState.sectionId)}
context={getSectionContext(imageModalState.sectionId)}
onImageGenerated={(imageBase64, sectionId) => {
console.time('[SectionImages] onImageGenerated');
if (sectionId && setSectionImages) {
setSectionImages((prev: Record<string, string>) => ({ ...prev, [sectionId]: imageBase64 }));
try {
const existing = JSON.parse(localStorage.getItem('blog_section_images') || '{}');
existing[sectionId] = imageBase64;
localStorage.setItem('blog_section_images', JSON.stringify(existing));
const serialized = JSON.stringify(existing);
if (serialized.length > 4_000_000) {
console.warn(`[SectionImages] Approaching localStorage quota: ${(serialized.length / 1024 / 1024).toFixed(1)}MB`);
}
localStorage.setItem('blog_section_images', serialized);
console.timeLog('[SectionImages] onImageGenerated', `saved sectionId=${sectionId} base64_len=${imageBase64.length}`);
} catch (e) {
console.warn('[SectionImages] Failed to persist to localStorage:', e);
}
}
} else {
console.warn('[SectionImages] Skipped: sectionId=', sectionId, 'setSectionImages=', !!setSectionImages);
}
console.timeEnd('[SectionImages] onImageGenerated');
}}
/>
)}

View File

@@ -16,6 +16,7 @@ import { useMarkdownProcessor } from '../../../hooks/useMarkdownProcessor';
import BlogPreviewModal from '../BlogPreviewModal';
import PlayAllTTSButton from '../PlayAllTTSButton';
import OnThisPageNav from './OnThisPageNav';
import { debug } from '../../../utils/debug';
const theme = createTheme({
typography: {
@@ -34,8 +35,10 @@ interface BlogEditorProps {
researchTitles?: string[];
aiGeneratedTitles?: string[];
sections?: Record<string, string>;
introduction?: string;
onContentUpdate?: (sections: any[]) => void;
onSave?: (content: any) => void;
onIntroductionUpdate?: (intro: string) => void;
continuityRefresh?: number;
flowAnalysisResults?: any;
sectionImages?: Record<string, string>;
@@ -51,8 +54,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
researchTitles = [],
aiGeneratedTitles = [],
sections: parentSections,
introduction: parentIntroduction,
onContentUpdate,
onSave,
onIntroductionUpdate,
continuityRefresh,
flowAnalysisResults,
sectionImages = {},
@@ -60,7 +65,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
groundingInsights
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('');
const [introduction, setIntroduction] = useState(parentIntroduction || '');
const [sections, setSections] = useState<any[]>([]);
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
@@ -72,6 +77,8 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [currentSectionId, setCurrentSectionId] = useState<string | number | null>(null);
const sectionsRef = useRef(sections);
useEffect(() => { sectionsRef.current = sections; }, [sections]);
const titleInputRef = useRef<HTMLInputElement>(null);
const introInputRef = useRef<HTMLInputElement>(null);
const contentContainerRef = useRef<HTMLDivElement>(null);
@@ -132,66 +139,114 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
}, []);
// Match the key generation used by getPrimaryKeyForOutlineSection in useSEOManager
const getOutlineKey = useCallback((section: any, index: number): string => {
const raw = section?.id ?? section?.section_id ?? section?.sectionId ?? section?.sectionID ?? `section_${index + 1}`;
return String(raw).trim();
}, []);
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
id: section.id || index + 1,
title: section.heading,
content: parentSections?.[section.id] || section.key_points?.join(' ') || '',
wordCount: section.target_words || 0,
sources: section.references?.length || 0,
outlineData: {
subheadings: section.subheadings || [],
keyPoints: section.key_points || [],
keywords: section.keywords || [],
references: section.references || [],
targetWords: section.target_words || 0
}
}));
const initialSections = outline.map((section, index) => {
const key = getOutlineKey(section, index);
return {
id: key,
title: section.heading,
content: parentSections?.[key] || parentSections?.[section.id] || parentSections?.[index + 1] || parentSections?.[`section_${index + 1}`] || section.key_points?.join(' ') || '',
wordCount: section.target_words || 0,
sources: section.references?.length || 0,
outlineData: {
subheadings: section.subheadings || [],
keyPoints: section.key_points || [],
keywords: section.keywords || [],
references: section.references || [],
targetWords: section.target_words || 0
}
};
});
setSections(initialSections);
}
}, [outline, parentSections]);
}, [outline, parentSections, getOutlineKey]);
const prevParentSectionsRef = useRef<string>('');
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
const lastSyncKeyRef = useRef<number>(0);
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
const parentSectionsString = JSON.stringify(parentSections);
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
return;
}
prevParentSectionsRef.current = parentSectionsString;
prevContinuityRefreshRef.current = continuityRefresh;
setSections(prevSections => {
const updatedSections = prevSections.map(section => {
const sectionIdStr = String(section.id);
const parentContent = parentSections[section.id] ||
parentSections[sectionIdStr] ||
parentSections[Number(section.id)];
if (parentContent !== undefined && parentContent !== section.content) {
return { ...section, content: parentContent };
// Generate all candidate keys that getIdCandidatesForSection might produce
const getSectionCandidates = useCallback((sectionId: string, index: number): string[] => {
const candidates: string[] = [sectionId, sectionId.toLowerCase()];
candidates.push(`section_${index + 1}`, `Section ${index + 1}`, `section${index + 1}`, `s${index + 1}`, `S${index + 1}`, `${index + 1}`);
const asNum = Number(sectionId);
if (!isNaN(asNum)) candidates.push(String(asNum));
return Array.from(new Set(candidates));
}, []);
// Helper: sync local sections from parentSections, returns true if any content changed
const syncFromParentSections = useCallback(() => {
if (!parentSections || !outline || outline.length === 0) return false;
const currentSections = sectionsRef.current;
let didSync = false;
const updatedSections = currentSections.map((section, index) => {
const candidates = getSectionCandidates(String(section.id), index);
let parentContent: string | undefined;
for (const candidate of candidates) {
if (parentSections[candidate] !== undefined) {
parentContent = parentSections[candidate];
break;
}
return section;
});
const hasUpdates = updatedSections.some((section, index) =>
section.content !== prevSections[index]?.content
);
if (onContentUpdate && hasUpdates) {
}
if (parentContent !== undefined && parentContent !== section.content) {
didSync = true;
return { ...section, content: parentContent };
}
return section;
});
if (didSync) {
setSections(updatedSections);
if (onContentUpdate) {
onContentUpdate(updatedSections);
}
return updatedSections;
});
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
}
return didSync;
}, [parentSections, outline, onContentUpdate, getSectionCandidates]);
// Effect 1: sync when parentSections content reference changes
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
const parentSectionsString = JSON.stringify(parentSections);
if (parentSectionsString === prevParentSectionsRef.current) return;
prevParentSectionsRef.current = parentSectionsString;
const synced = syncFromParentSections();
// Diagnostic: log key alignment between parentSections and outline-derived keys
const parentKeys = Object.keys(parentSections);
const outlineKeys = outline.map((s: any, i: number) => getOutlineKey(s, i));
if (!synced) {
debug.log('[BlogEditor] parentSections changed but sync found no updates', {
parentKeys,
outlineKeys,
notInParent: outlineKeys.filter(k => !parentKeys.includes(k)),
notInOutline: parentKeys.filter(k => !outlineKeys.includes(k)),
});
}
}, [parentSections, syncFromParentSections]);
// Sync introduction from parent when it changes
useEffect(() => {
if (parentIntroduction !== undefined && parentIntroduction !== introduction) {
setIntroduction(parentIntroduction);
debug.log('[BlogEditor] Introduction synced from parent', {
length: parentIntroduction.length,
});
}
}, [parentIntroduction]);
// Effect 2: explicit forced sync via continuityRefresh (bypasses content-equality guard)
useEffect(() => {
if (continuityRefresh === undefined || continuityRefresh === 0) return;
if (lastSyncKeyRef.current === continuityRefresh) return;
lastSyncKeyRef.current = continuityRefresh;
const synced = syncFromParentSections();
debug.log('[BlogEditor] continuityRefresh sync', { key: continuityRefresh, synced });
}, [continuityRefresh, syncFromParentSections]);
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
@@ -272,8 +327,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const handleIntroductionSelect = useCallback((selectedIntroduction: string) => {
setIntroduction(selectedIntroduction);
onIntroductionUpdate?.(selectedIntroduction);
setShowIntroductionModal(false);
}, []);
}, [onIntroductionUpdate]);
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
@@ -297,7 +353,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
return (
<ThemeProvider theme={theme}>
<div className="min-h-screen bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-7xl mx-auto px-[2%] py-6">
<div className="flex gap-8">
{/* Main editor column */}
<div className="flex-1 min-w-0 max-w-4xl" ref={contentContainerRef}>
@@ -367,7 +423,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
multiline
minRows={2}
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
onChange={(e) => {
setIntroduction(e.target.value);
onIntroductionUpdate?.(e.target.value);
}}
onBlur={() => setEditingIntro(false)}
placeholder="Write an engaging introduction..."
InputProps={{

View File

@@ -77,6 +77,101 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const wordCount_ = useMemo(() => content.split(/\s+/).filter(Boolean).length, [content]);
const handleFormatText = useCallback((formatType: string, startPos?: number, endPos?: number) => {
const textarea = contentRef.current;
if (!textarea) return;
const start = startPos ?? textarea.selectionStart;
const end = endPos ?? textarea.selectionEnd;
const selected = content.substring(start, end);
const trimmed = selected.trim();
let replacement: string;
let cursorPos: number;
switch (formatType) {
case 'bold': {
const outerMatch = trimmed.match(/^\*\*(.+)\*\*$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `**${trimmed.replace(/\*\*/g, '')}**`;
}
cursorPos = start + replacement.length;
break;
}
case 'italic': {
const outerMatch = trimmed.match(/^\*(?!\*)(.+)(?<!\*)\*$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `*${trimmed.replace(/\*/g, '')}*`;
}
cursorPos = start + replacement.length;
break;
}
case 'link': {
replacement = trimmed ? `[${trimmed}](url)` : `[text](url)`;
cursorPos = trimmed ? start + replacement.length - 5 : start + 1;
break;
}
case 'heading-2': {
replacement = trimmed ? `## ${trimmed}` : `## Heading`;
cursorPos = start + replacement.length;
break;
}
case 'heading-3': {
replacement = trimmed ? `### ${trimmed}` : `### Heading`;
cursorPos = start + replacement.length;
break;
}
case 'bullet-list': {
replacement = trimmed ? `- ${trimmed}` : `- List item`;
cursorPos = start + replacement.length;
break;
}
case 'numbered-list': {
replacement = trimmed ? `1. ${trimmed}` : `1. List item`;
cursorPos = start + replacement.length;
break;
}
case 'blockquote': {
replacement = trimmed ? `> ${trimmed}` : `> Quote`;
cursorPos = start + replacement.length;
break;
}
case 'code': {
const outerMatch = trimmed.match(/^`(.+)`$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `\`${trimmed.replace(/`/g, '')}\``;
}
cursorPos = start + replacement.length;
break;
}
case 'hr': {
replacement = `\n\n---\n\n`;
cursorPos = start + replacement.length;
break;
}
default:
return;
}
const newContent = content.substring(0, start) + replacement + content.substring(end);
setContent(newContent);
if (onContentUpdate) onContentUpdate([{ id, content: newContent }]);
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: { originalText: selected, editedText: replacement, editType: 'format' }
}));
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(cursorPos, cursorPos);
});
}, [content, id, onContentUpdate]);
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
@@ -91,7 +186,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
setContent(updatedContent);
if (onContentUpdate) onContentUpdate([{ id, content: updatedContent }]);
}
}
},
handleFormatText
);
const formatContent = (rawContent: string) => {
@@ -352,11 +448,11 @@ const BlogSection: React.FC<BlogSectionProps> = ({
{sectionImage && (
<div className="mb-4">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white max-w-full mx-auto" style={{ maxWidth: 'min(100%, 720px)' }}>
<img
src={sectionImage.startsWith('http') || sectionImage.startsWith('/api/') ? sectionImage : `data:image/png;base64,${sectionImage}`}
alt={`Image for ${sectionTitle}`}
className="w-full h-auto max-h-96 object-contain"
className="block w-full max-w-full h-auto max-h-96 object-contain mx-auto"
/>
</div>
</div>
@@ -382,6 +478,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
color: '#1f2937',
'& h1, & h2, & h3': { color: '#111827', mt: 2, mb: 1 },
'& h2': { fontSize: '1.5rem', fontWeight: 600, borderBottom: '1px solid #e5e7eb', pb: 1 },
'& h3': { fontSize: '1.25rem', fontWeight: 600 },
'& h4': { fontSize: '1.15rem', fontWeight: 600, color: '#1e293b', mt: 1.5, mb: 0.5 },
'& h5, & h6': { fontSize: '1rem', fontWeight: 600, color: '#334155', mt: 1.5, mb: 0.5 },
'& p': { mb: 1.5 },
'& strong': { fontWeight: 600 },
'& em': { fontStyle: 'italic' },
@@ -402,10 +501,39 @@ const BlogSection: React.FC<BlogSectionProps> = ({
fontFamily: 'monospace',
fontSize: '0.9em',
},
'& kbd': {
bgcolor: '#f1f5f9',
border: '1px solid #d1d5db',
borderRadius: '4px',
px: 1,
py: 0.25,
fontFamily: 'monospace',
fontSize: '0.85em',
boxShadow: '0 1px 0 #d1d5db',
},
'& mark': { bgcolor: '#fef3c7', color: '#92400e', px: 0.5, borderRadius: 0.25 },
'& sub, & sup': { fontSize: '0.75em', lineHeight: 1 },
'& details': { mb: 1.5 },
'& details summary': { cursor: 'pointer', fontWeight: 600, color: '#1e293b' },
'& details summary:hover': { color: '#4f46e5' },
'& dl': { mb: 1.5 },
'& dl dt': { fontWeight: 600, color: '#1e293b', mt: 1 },
'& dl dd': { ml: 2, color: '#4b5563' },
'& abbr': { cursor: 'help', textDecoration: 'underline dotted #94a3b8' },
'& ul, & ol': { pl: 2, mb: 1.5 },
'& li': { mb: 0.5 },
'& hr': { borderColor: '#e5e7eb', my: 2 },
'& img': { maxWidth: '100%', height: 'auto', borderRadius: 1 },
'& table': { borderCollapse: 'collapse', width: '100%', mb: 2, fontSize: '0.95rem' },
'& th, & td': { border: '1px solid #d1d5db', px: 2, py: 1, textAlign: 'left' },
'& th': { bgcolor: '#f3f4f6', fontWeight: 600 },
'& tr:nth-of-type(even)': { bgcolor: '#f9fafb' },
'& .table-wrapper': { overflowX: 'auto', mb: 2 },
'& .table-wrapper table': { mb: 0 },
'& pre': { bgcolor: '#1e293b', color: '#e2e8f0', p: 2.5, borderRadius: 1, overflowX: 'auto', fontFamily: 'monospace', fontSize: '0.875rem', lineHeight: 1.5, mb: 2 },
'& pre code': { bgcolor: 'transparent', color: 'inherit', p: 0, fontSize: 'inherit', lineHeight: 'inherit' },
'& del': { color: '#991b1b', textDecoration: 'line-through' },
'& input[type="checkbox"]': { mr: 1, transform: 'scale(1.1)', accentColor: '#4f46e5' },
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(content) }}
/>
@@ -417,7 +545,7 @@ const BlogSection: React.FC<BlogSectionProps> = ({
multiline
fullWidth
variant="outlined"
placeholder="Start writing..."
placeholder="Start writing... Use the toolbar above to format text, or type markdown directly."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
@@ -426,14 +554,19 @@ const BlogSection: React.FC<BlogSectionProps> = ({
inputRef={contentRef}
minRows={5}
InputProps={{
className: `font-serif text-base leading-relaxed text-gray-700 p-0 ${isFocused ? 'bg-white' : 'bg-transparent'}`,
style: { lineHeight: '1.8' }
className: `font-serif text-base leading-relaxed text-gray-700 ${isFocused ? 'bg-white' : 'bg-gray-50/30'}`,
style: { lineHeight: '1.8', padding: '12px 16px' },
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-notchedOutline': {
border: '1px solid #e2e8f0',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
'& .MuiOutlinedInput-root': { padding: 0 },
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#4f46e5', borderWidth: 2 },
'& .MuiInputBase-input': { padding: '12px 16px !important' },
}}
/>
</div>

View File

@@ -1,22 +1,23 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import { chartApi, ChartGenerateResponse } from '../../../services/chartApi';
import TextSelectionMenu from './TextSelectionMenu';
import CompactSelectionMenu from './CompactSelectionMenu';
import ChartGeneratorModal from '../../Chart/ChartGeneratorModal';
import LinkSearchModal from '../../Link/LinkSearchModal';
import useSmartTypingAssist from './SmartTypingAssist';
// import { debug } from '../../../utils/debug'; // Unused import
interface BlogTextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
onFormatText?: (type: string, start?: number, end?: number) => void;
}
const useBlogTextSelectionHandler = (
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
onTextReplace?: (originalText: string, newText: string, editType: string) => void
onTextReplace?: (originalText: string, newText: string, editType: string) => void,
onFormatText?: (type: string, start?: number, end?: number) => void,
) => {
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; start: number; end: number } | null>(null);
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
@@ -27,22 +28,13 @@ const useBlogTextSelectionHandler = (
const [linkModalText, setLinkModalText] = useState('');
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use the extracted smart typing assist hook
const smartTypingAssist = useSmartTypingAssist(contentRef, onTextReplace);
// Fact-checking functionality
const handleCheckFacts = async (text: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleCheckFacts called with text:', text);
if (!text.trim()) {
console.log('🔍 [BlogTextSelectionHandler] No text to check, returning');
return;
}
console.log('🔍 [BlogTextSelectionHandler] Starting fact check for:', text.trim());
if (!text.trim()) return;
setIsFactChecking(true);
setSelectionMenu(null);
// Progress tracking
const progressSteps = [
{ step: "Extracting verifiable claims...", progress: 20 },
{ step: "Searching for evidence...", progress: 40 },
@@ -52,18 +44,14 @@ const useBlogTextSelectionHandler = (
];
let currentStepIndex = 0;
// Start progress updates
const progressInterval = setInterval(() => {
if (currentStepIndex < progressSteps.length) {
setFactCheckProgress(progressSteps[currentStepIndex]);
currentStepIndex++;
}
}, 2000); // Update every 2 seconds
}, 2000);
// Set a timeout for the fact check (120 seconds)
const timeoutId = setTimeout(() => {
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
clearInterval(progressInterval);
setFactCheckProgress(null);
setIsFactChecking(false);
@@ -78,20 +66,16 @@ const useBlogTextSelectionHandler = (
timestamp: new Date().toISOString(),
error: 'Fact check timed out after 120 seconds. Please try again with shorter text.'
});
}, 120000); // 120 second timeout
}, 120000);
try {
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
const results = await hallucinationDetectorService.detectHallucinations({
text: text.trim(),
include_sources: true,
max_claims: 10
});
console.log('🔍 [BlogTextSelectionHandler] Fact check results received:', results);
setFactCheckResults(results);
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error checking facts:', error);
setFactCheckResults({
success: false,
claims: [],
@@ -104,7 +88,6 @@ const useBlogTextSelectionHandler = (
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
});
} finally {
console.log('🔍 [BlogTextSelectionHandler] Fact check completed, setting isFactChecking to false');
clearInterval(progressInterval);
clearTimeout(timeoutId);
setFactCheckProgress(null);
@@ -116,7 +99,6 @@ const useBlogTextSelectionHandler = (
setFactCheckResults(null);
};
// Chart generation handler
const handleGenerateChart = (text: string) => {
setChartModalText(text);
setChartModalOpen(true);
@@ -148,28 +130,22 @@ const useBlogTextSelectionHandler = (
setLinkModalOpen(false);
};
// Blog-specific quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
let editedText = selectedText;
switch (editType) {
case 'improve':
// Enhance readability and engagement
editedText = selectedText.replace(/\./g, '. ').replace(/\s+/g, ' ').trim();
if (!editedText.endsWith('.') && !editedText.endsWith('!') && !editedText.endsWith('?')) {
editedText += '.';
}
break;
case 'add-transition':
// Add transitional phrases
const transitions = ['Furthermore,', 'Additionally,', 'Moreover,', 'In essence,', 'As a result,'];
const randomTransition = transitions[Math.floor(Math.random() * transitions.length)];
editedText = `${randomTransition} ${selectedText.toLowerCase()}`;
break;
case 'shorten':
// Condense while maintaining meaning
editedText = selectedText
.replace(/\b(very|really|extremely|quite|rather|fairly)\s+/gi, '')
.replace(/\b(that|which) (is|are|was|were)\s+/gi, '')
@@ -178,11 +154,9 @@ const useBlogTextSelectionHandler = (
.trim();
break;
case 'expand':
// Add explanatory content
editedText = selectedText + ' This approach provides significant value by offering concrete benefits and actionable insights that readers can immediately implement.';
break;
case 'professionalize':
// Make more formal and professional
editedText = selectedText
.replace(/\bcan't\b/gi, 'cannot')
.replace(/\bwon't\b/gi, 'will not')
@@ -193,19 +167,16 @@ const useBlogTextSelectionHandler = (
.replace(/\bI believe\b/gi, 'Research indicates that');
break;
case 'add-data':
// Add statistical backing
editedText = selectedText + ' According to recent industry studies, this approach has shown measurable improvements in key performance metrics.';
break;
default:
return;
}
// Call the callback with the edited text
if (onTextReplace) {
onTextReplace(selectedText, editedText, editType);
}
// Also dispatch custom event for broader compatibility
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: {
originalText: selectedText,
@@ -214,12 +185,9 @@ const useBlogTextSelectionHandler = (
}
}));
// Close the selection menu
setSelectionMenu(null);
};
// Close selection menu when clicking outside any selection menu
useEffect(() => {
if (!selectionMenu) return;
@@ -240,7 +208,6 @@ const useBlogTextSelectionHandler = (
};
}, [selectionMenu]);
// Cleanup progress and timeouts on unmount
useEffect(() => {
return () => {
setFactCheckProgress(null);
@@ -250,7 +217,6 @@ const useBlogTextSelectionHandler = (
};
}, []);
// Text selection handler with debouncing
const handleTextSelection = () => {
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
@@ -260,26 +226,27 @@ const useBlogTextSelectionHandler = (
try {
let text = '';
let rect: DOMRect | null = null;
let startPos = 0;
let endPos = 0;
const el = contentRef.current;
if (el instanceof HTMLTextAreaElement) {
const start = el.selectionStart;
const end = el.selectionEnd;
startPos = start;
endPos = end;
if (start !== end) {
text = el.value.substring(start, end).trim();
try {
const { selectionStart, selectionEnd } = el;
if (selectionStart !== null && selectionEnd !== null) {
const textRect = el.getBoundingClientRect();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
const linesBefore = el.value.substring(0, selectionStart).split('\n').length - 1;
rect = new DOMRect(
textRect.left + 10,
textRect.top + (linesBefore * lineHeight) + 10,
100,
20
);
}
const textRect = el.getBoundingClientRect();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
const linesBefore = el.value.substring(0, start).split('\n').length - 1;
rect = new DOMRect(
textRect.left + 10,
textRect.top + (linesBefore * lineHeight) + 10,
100,
20
);
} catch (_) {}
}
} else {
@@ -302,7 +269,7 @@ const useBlogTextSelectionHandler = (
const elRect = el.getBoundingClientRect();
const x = Math.max(8, Math.min(elRect.left + (elRect.width / 2), window.innerWidth - 280));
const y = Math.max(8, elRect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
setSelectionMenu({ x, y, text, start: startPos, end: endPos });
return;
}
setSelectionMenu(null);
@@ -312,7 +279,7 @@ const useBlogTextSelectionHandler = (
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280));
const y = Math.max(8, rect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
setSelectionMenu({ x, y, text, start: startPos, end: endPos });
} catch (error) {
console.error('Text selection error:', error);
setSelectionMenu(null);
@@ -330,51 +297,57 @@ const useBlogTextSelectionHandler = (
handleCheckFacts,
handleCloseFactCheckResults,
handleQuickEdit,
// Smart typing assist functionality from extracted hook
...smartTypingAssist,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<>
<TextSelectionMenu
selectionMenu={selectionMenu}
factCheckResults={factCheckResults}
isFactChecking={isFactChecking}
factCheckProgress={factCheckProgress}
smartSuggestion={smartTypingAssist.smartSuggestion}
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
allSuggestions={smartTypingAssist.allSuggestions}
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onGenerateChart={handleGenerateChart}
onFindLinks={handleFindLinks}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
/>
{chartModalOpen && (
<ChartGeneratorModal
isOpen={chartModalOpen}
onClose={() => setChartModalOpen(false)}
defaultText={chartModalText}
onChartGenerated={handleChartGenerated}
<CompactSelectionMenu
selectionMenu={selectionMenu}
factCheckResults={factCheckResults}
isFactChecking={isFactChecking}
factCheckProgress={factCheckProgress}
smartSuggestion={smartTypingAssist.smartSuggestion}
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
allSuggestions={smartTypingAssist.allSuggestions}
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onGenerateChart={handleGenerateChart}
onFindLinks={handleFindLinks}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
onFormatText={(type: string) => {
if (selectionMenu) {
onFormatText?.(type, selectionMenu.start, selectionMenu.end);
} else {
onFormatText?.(type);
}
setSelectionMenu(null);
}}
/>
)}
{linkModalOpen && (
<LinkSearchModal
isOpen={linkModalOpen}
onClose={() => setLinkModalOpen(false)}
sectionHeading=""
sectionText={linkModalText}
selectedText={linkModalText}
onRewordAccept={handleLinkRewordAccept}
/>
)}
</>
{chartModalOpen && (
<ChartGeneratorModal
isOpen={chartModalOpen}
onClose={() => setChartModalOpen(false)}
defaultText={chartModalText}
onChartGenerated={handleChartGenerated}
/>
)}
{linkModalOpen && (
<LinkSearchModal
isOpen={linkModalOpen}
onClose={() => setLinkModalOpen(false)}
sectionHeading=""
sectionText={linkModalText}
selectedText={linkModalText}
onRewordAccept={handleLinkRewordAccept}
/>
)}
</>
)
};
};

View File

@@ -0,0 +1,260 @@
import React from 'react';
import { Box, Tooltip, IconButton, Divider } from '@mui/material';
import {
FormatBold as BoldIcon,
FormatItalic as ItalicIcon,
Link as LinkIcon,
FormatListBulleted as BulletListIcon,
FormatListNumbered as NumberedListIcon,
FormatQuote as QuoteIcon,
Code as CodeIcon,
HorizontalRule as HrIcon,
Title as TitleIcon,
} from '@mui/icons-material';
interface CompactSelectionMenuProps {
selectionMenu: { x: number; y: number; text: string; start: number; end: number } | null;
factCheckResults: any;
isFactChecking: boolean;
factCheckProgress: { step: string; progress: number } | null;
smartSuggestion: any;
isGeneratingSuggestion: boolean;
allSuggestions: any[];
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onGenerateChart: (text: string) => void;
onFindLinks: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
onRejectSuggestion: () => void;
onNextSuggestion: () => void;
onRequestSuggestion: () => void;
onDismissPrompt: () => void;
onFormatText: (type: string, start?: number, end?: number) => void;
}
const formatBtnSx = {
width: 30,
height: 30,
borderRadius: '6px',
color: 'rgba(255,255,255,0.85)',
transition: 'all 0.15s ease',
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)', color: 'white' },
};
const formatButtons = [
{ type: 'bold', icon: <BoldIcon sx={{ fontSize: 16 }} />, label: 'Bold' },
{ type: 'italic', icon: <ItalicIcon sx={{ fontSize: 16 }} />, label: 'Italic' },
{ type: 'link', icon: <LinkIcon sx={{ fontSize: 16 }} />, label: 'Link' },
{ type: 'heading-2', icon: <TitleIcon sx={{ fontSize: 15, transform: 'scaleX(1.2)' }} />, label: 'H2' },
{ type: 'heading-3', icon: <TitleIcon sx={{ fontSize: 13, transform: 'scaleX(1.1)' }} />, label: 'H3' },
{ type: 'bullet-list', icon: <BulletListIcon sx={{ fontSize: 16 }} />, label: 'Bullet' },
{ type: 'numbered-list', icon: <NumberedListIcon sx={{ fontSize: 16 }} />, label: 'Numbered' },
{ type: 'blockquote', icon: <QuoteIcon sx={{ fontSize: 16 }} />, label: 'Quote' },
{ type: 'code', icon: <CodeIcon sx={{ fontSize: 16 }} />, label: 'Code' },
{ type: 'hr', icon: <HrIcon sx={{ fontSize: 16 }} />, label: 'HR' },
];
const aiButtons = [
{ type: 'improve', label: '✏️ Improve Shorten Expand' },
];
const btnBase: React.CSSProperties = {
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '5px 10px',
color: 'white',
fontSize: '11px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
};
const CompactSelectionMenu: React.FC<CompactSelectionMenuProps> = ({
selectionMenu,
factCheckResults,
isFactChecking,
factCheckProgress,
smartSuggestion,
isGeneratingSuggestion,
allSuggestions,
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onGenerateChart,
onFindLinks,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
onRejectSuggestion,
onNextSuggestion,
onRequestSuggestion,
onDismissPrompt,
onFormatText,
}) => {
if (!selectionMenu) return null;
const x = Math.max(8, Math.min(selectionMenu.x - 180, window.innerWidth - 380));
return (
<div
data-selection-menu="true"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: selectionMenu.y - 10,
left: x,
background: 'rgba(79, 70, 229, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
padding: '8px 10px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(12px)',
zIndex: 10000,
minWidth: '340px',
maxWidth: '380px',
}}
>
{/* Formatting Section */}
<div>
<div style={{
fontSize: '10px',
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
textTransform: 'uppercase',
letterSpacing: '0.8px',
marginBottom: '4px',
paddingLeft: '2px',
}}>
Format
</div>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{formatButtons.map(btn => (
<Tooltip key={btn.type} title={btn.label} arrow>
<IconButton size="small" sx={formatBtnSx} onClick={() => onFormatText(btn.type)}>
{btn.icon}
</IconButton>
</Tooltip>
))}
</Box>
</div>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.15)', my: '4px' }} />
{/* AI Tools Section */}
<div>
<div style={{
fontSize: '10px',
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
textTransform: 'uppercase',
letterSpacing: '0.8px',
marginBottom: '4px',
paddingLeft: '2px',
}}>
🤖 AI Tools
</div>
{/* Primary AI Actions */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); onCheckFacts(selectionMenu.text); }}
disabled={isFactChecking}
style={{
...btnBase,
background: isFactChecking ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.15)',
opacity: isFactChecking ? 0.6 : 1,
cursor: isFactChecking ? 'not-allowed' : 'pointer',
flex: 1,
justifyContent: 'center',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => { if (!isFactChecking) e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
onMouseLeave={(e) => { if (!isFactChecking) e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
>
{isFactChecking ? '⏳ Verifying...' : '🔍 Fact Check'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onGenerateChart(selectionMenu.text); }}
style={{ ...btnBase, flex: 1, justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(124,58,237,0.3)', border: '1px solid rgba(124,58,237,0.4)' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124,58,237,0.45)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124,58,237,0.3)'; }}
>
📊 Chart
</button>
<button
onClick={(e) => { e.stopPropagation(); onFindLinks(selectionMenu.text); }}
style={{ ...btnBase, flex: 1, justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(16,185,129,0.3)', border: '1px solid rgba(16,185,129,0.4)' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(16,185,129,0.45)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(16,185,129,0.3)'; }}
>
🔗 Links
</button>
</div>
{/* Quick Edit Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '4px',
}}>
{['improve', 'shorten', 'expand', 'professionalize', 'add-transition', 'add-data'].map(type => {
const labels: Record<string, string> = {
improve: '✏️ Improve',
shorten: '✂️ Shorten',
expand: '📝 Expand',
professionalize: '🎓 Professional',
'add-transition': '🔗 Transition',
'add-data': '📊 Add Data',
};
return (
<button
key={type}
onClick={(e) => { e.stopPropagation(); onQuickEdit(type, selectionMenu.text); }}
style={btnBase}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
>
{labels[type]}
</button>
);
})}
</div>
</div>
{/* Fact Check Progress */}
{factCheckProgress && (
<div style={{
marginTop: '4px',
padding: '6px 8px',
borderTop: '1px solid rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<div style={{
width: '14px',
height: '14px',
border: '2px solid rgba(255,255,255,0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<span style={{ fontSize: '11px', color: 'rgba(255,255,255,0.85)' }}>{factCheckProgress.step}</span>
</div>
)}
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
);
};
export default CompactSelectionMenu;

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { Box, Tooltip, IconButton, Divider } from '@mui/material';
import {
FormatBold as BoldIcon,
FormatItalic as ItalicIcon,
Link as LinkIcon,
FormatListBulleted as BulletListIcon,
FormatListNumbered as NumberedListIcon,
FormatQuote as QuoteIcon,
Code as CodeIcon,
HorizontalRule as HrIcon,
Title as TitleIcon,
} from '@mui/icons-material';
interface MarkdownToolbarProps {
onFormat: (type: string) => void;
}
interface ToolbarButton {
type: string;
icon: React.ReactNode;
tooltip: string;
shortcut?: string;
}
const buttons: ToolbarButton[] = [
{ type: 'bold', icon: <BoldIcon sx={{ fontSize: 18 }} />, tooltip: 'Bold', shortcut: 'Ctrl+B' },
{ type: 'italic', icon: <ItalicIcon sx={{ fontSize: 18 }} />, tooltip: 'Italic', shortcut: 'Ctrl+I' },
{ type: 'link', icon: <LinkIcon sx={{ fontSize: 18 }} />, tooltip: 'Insert Link' },
];
const headingButtons: ToolbarButton[] = [
{ type: 'heading-2', icon: <TitleIcon sx={{ fontSize: 18, transform: 'scaleX(1.3)' }} />, tooltip: 'Subheading (H2)' },
{ type: 'heading-3', icon: <TitleIcon sx={{ fontSize: 15, transform: 'scaleX(1.2)' }} />, tooltip: 'Subheading (H3)' },
];
const listButtons: ToolbarButton[] = [
{ type: 'bullet-list', icon: <BulletListIcon sx={{ fontSize: 18 }} />, tooltip: 'Bullet List' },
{ type: 'numbered-list', icon: <NumberedListIcon sx={{ fontSize: 18 }} />, tooltip: 'Numbered List' },
];
const blockButtons: ToolbarButton[] = [
{ type: 'blockquote', icon: <QuoteIcon sx={{ fontSize: 18 }} />, tooltip: 'Blockquote' },
{ type: 'code', icon: <CodeIcon sx={{ fontSize: 18 }} />, tooltip: 'Inline Code' },
{ type: 'hr', icon: <HrIcon sx={{ fontSize: 18 }} />, tooltip: 'Horizontal Rule' },
];
const btnSx = {
width: 30,
height: 30,
borderRadius: '6px',
color: '#64748b',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: '#eef2ff',
color: '#4f46e5',
},
};
const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({ onFormat }) => {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
borderBottom: 'none',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
}}
>
{buttons.map(btn => (
<Tooltip key={btn.type} title={btn.shortcut ? `${btn.tooltip} (${btn.shortcut})` : btn.tooltip} arrow>
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
{btn.icon}
</IconButton>
</Tooltip>
))}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
{headingButtons.map(btn => (
<Tooltip key={btn.type} title={btn.tooltip} arrow>
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
{btn.icon}
</IconButton>
</Tooltip>
))}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
{listButtons.map(btn => (
<Tooltip key={btn.type} title={btn.tooltip} arrow>
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
{btn.icon}
</IconButton>
</Tooltip>
))}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
{blockButtons.map(btn => (
<Tooltip key={btn.type} title={btn.tooltip} arrow>
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
{btn.icon}
</IconButton>
</Tooltip>
))}
</Box>
);
};
export default MarkdownToolbar;

View File

@@ -1,741 +0,0 @@
import React from 'react';
import { HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
interface TextSelectionMenuProps {
selectionMenu: { x: number; y: number; text: string } | null;
factCheckResults: HallucinationDetectionResponse | null;
isFactChecking: boolean;
factCheckProgress: { step: string; progress: number } | null;
smartSuggestion: {
text: string;
position: { x: number; y: number };
confidence?: number;
sources?: Array<{
title: string;
url: string;
text?: string;
author?: string;
published_date?: string;
score: number;
}>;
} | null;
isGeneratingSuggestion: boolean;
allSuggestions: Array<{
text: string;
confidence?: number;
sources?: Array<{
title: string;
url: string;
text?: string;
author?: string;
published_date?: string;
score: number;
}>;
}>;
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onGenerateChart: (text: string) => void;
onFindLinks: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
onRejectSuggestion: () => void;
onNextSuggestion: () => void;
onRequestSuggestion: () => void;
onDismissPrompt: () => void;
}
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
selectionMenu,
factCheckResults,
isFactChecking,
factCheckProgress,
smartSuggestion,
isGeneratingSuggestion,
allSuggestions,
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onGenerateChart,
onFindLinks,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
onRejectSuggestion,
onNextSuggestion,
onRequestSuggestion,
onDismissPrompt
}) => {
return (
<>
{/* Text Selection Menu */}
{selectionMenu && (
<div
data-selection-menu="true"
onClick={(e) => {
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
e.stopPropagation();
}}
style={{
position: 'fixed',
top: selectionMenu.y - 60,
left: Math.max(8, selectionMenu.x - 140),
background: 'rgba(79, 70, 229, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
padding: '12px 16px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(12px)',
zIndex: 10000,
minWidth: '240px',
maxWidth: '280px'
}}
>
{/* Fact Check Button */}
<button
onClick={(e) => {
console.log('🔍 [TextSelectionMenu] Check Facts button clicked!', selectionMenu.text);
e.preventDefault();
e.stopPropagation();
onCheckFacts(selectionMenu.text);
}}
disabled={isFactChecking}
style={{
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: isFactChecking ? 'not-allowed' : 'pointer',
opacity: isFactChecking ? 0.6 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}
}}
onMouseLeave={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}
}}
>
{isFactChecking ? (
<>
<div style={{
width: '14px',
height: '14px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
Fact-checking...
</>
) : (
<>
🔍 Fact Check
</>
)}
</button>
{/* Generate Chart Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onGenerateChart(selectionMenu.text);
}}
style={{
background: 'rgba(124, 58, 237, 0.2)',
border: '1px solid rgba(124, 58, 237, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)';
}}
>
📊 Generate Chart
</button>
{/* Find Links Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindLinks(selectionMenu.text);
}}
style={{
background: 'rgba(16, 185, 129, 0.2)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.2)';
}}
>
🔗 Find Links
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
paddingTop: '10px',
marginTop: '6px'
}}>
<div style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '11px',
fontWeight: '600',
marginBottom: '8px',
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Assistive Writing
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px'
}}>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('improve', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Improve
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('add-transition', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🔗 Transition
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('shorten', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Shorten
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('expand', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📝 Expand
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('professionalize', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🎓 Professional
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickEdit('add-data', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📊 Add Data
</button>
</div>
</div>
</div>
)}
{/* Fact Check Progress */}
{factCheckProgress && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: 'rgba(79, 70, 229, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '280px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Fact-checking content...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
{factCheckProgress.step}
</div>
</div>
</div>
)}
{/* Fact Check Results */}
{factCheckResults && (
<FactCheckResults
results={factCheckResults}
onClose={onCloseFactCheckResults}
/>
)}
{/* Smart Typing Suggestion */}
{smartSuggestion && (
<div
onClick={(e) => {
console.log('🔍 [TextSelectionMenu] Smart suggestion modal clicked!', smartSuggestion);
e.stopPropagation();
}}
style={{
position: 'fixed',
top: smartSuggestion.position.y,
left: smartSuggestion.position.x,
background: 'rgba(34, 197, 94, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
padding: '16px 20px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(12px)',
zIndex: 10002,
maxWidth: '420px',
minWidth: '320px',
maxHeight: '350px',
overflow: 'auto',
color: 'white'
}}
>
<div style={{
fontSize: '12px',
fontWeight: '600',
marginBottom: '8px',
opacity: 0.9,
textTransform: 'uppercase',
letterSpacing: '0.5px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span> Smart Writing Suggestion</span>
{allSuggestions.length > 1 && (
<span style={{ fontSize: '10px', opacity: 0.7 }}>
{suggestionIndex + 1} of {allSuggestions.length}
</span>
)}
</div>
<div style={{
fontSize: '14px',
lineHeight: '1.4',
marginBottom: '16px',
fontStyle: 'italic'
}}>
"{smartSuggestion.text}"
</div>
{smartSuggestion.sources && smartSuggestion.sources.length > 0 && (
<div style={{
marginBottom: '12px',
borderTop: '1px solid rgba(255,255,255,0.2)',
paddingTop: '10px'
}}>
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.8, marginBottom: '6px' }}>
Sources:
</div>
{smartSuggestion.sources.slice(0, 2).map((src, i) => (
<div key={i} style={{ fontSize: '11px', opacity: 0.85, marginBottom: '4px', lineHeight: '1.3' }}>
<a href={src.url} target="_blank" rel="noopener noreferrer"
style={{ color: 'white', textDecoration: 'underline' }}
onClick={(e) => e.stopPropagation()}>
{src.title || src.url}
</a>
</div>
))}
</div>
)}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', gap: '8px' }}>
{allSuggestions.length > 1 && suggestionIndex < allSuggestions.length - 1 && (
<button
onClick={onNextSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}}
>
Next
</button>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={onRejectSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Dismiss
</button>
<button
onClick={onAcceptSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Accept
</button>
</div>
</div>
</div>
)}
{/* Smart Suggestion Loading Indicator */}
{isGeneratingSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(34, 197, 94, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '240px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Generating suggestion...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
AI is crafting helpful content
</div>
</div>
</div>
)}
{/* Continue Writing Prompt */}
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(59, 130, 246, 0.95)',
color: 'white',
padding: '16px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
minWidth: '280px',
maxWidth: '360px'
}}>
<div style={{
fontSize: '13px',
fontWeight: '600',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
AI Writing Assistant
</div>
<div style={{
fontSize: '12px',
opacity: 0.9,
marginBottom: '16px',
lineHeight: '1.5'
}}>
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
</div>
<div style={{
display: 'flex',
gap: '8px'
}}>
<button
onClick={onRequestSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Continue Writing
</button>
<button
onClick={onDismissPrompt}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}}
>
</button>
</div>
</div>
)}
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</>
);
};
export default TextSelectionMenu;

View File

@@ -214,6 +214,8 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
}, [negative]);
const suggestPrompt = async () => {
console.time('[suggestPrompt] total');
console.time('[suggestPrompt] pre-call');
setLoadingSuggestions(true);
setSuggestionError(null);
try {
@@ -225,7 +227,10 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
research: context?.research || undefined,
persona: context?.persona || undefined,
};
console.timeLog('[suggestPrompt] pre-call', 'calling fetchPromptSuggestions');
console.time('[suggestPrompt] fetchPromptSuggestions');
const suggs = await fetchPromptSuggestions(payload);
console.timeLog('[suggestPrompt] fetchPromptSuggestions', 'response received');
setSuggestions(suggs);
if (suggs.length > 0) {
setPrompt(suggs[0].prompt || '');
@@ -238,10 +243,13 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
setSuggestionError(e instanceof Error ? e.message : 'Failed to optimize prompt. The API is unavailable.');
} finally {
setLoadingSuggestions(false);
console.timeLog('[suggestPrompt] total', 'done');
console.timeEnd('[suggestPrompt] total');
}
};
const onGenerate = async () => {
console.time('[onGenerate] total');
if (width > MAX_DIMENSIONS.maxWidth || height > MAX_DIMENSIONS.maxHeight) {
alert(`Resolution ${width}x${height} exceeds maximum ${MAX_DIMENSIONS.maxWidth}x${MAX_DIMENSIONS.maxHeight} for model ${model}. Please adjust the dimensions.`);
return;
@@ -256,12 +264,15 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
height,
overlay_text: suggestion?.overlay_text || undefined,
};
console.time('[onGenerate] generate');
const res = await generate(req);
console.timeLog('[onGenerate] generate', 'done');
if (res && onImageReady) onImageReady(res.image_base64);
try {
const { publishImage } = await import('../../utils/imageBus');
publishImage({ base64: res.image_base64, provider: res.provider, model: res.model });
} catch {}
console.timeEnd('[onGenerate] total');
};
useImperativeHandle(ref, () => ({

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { apiClient } from '../../api/client';
import { apiClient, aiApiClient } from '../../api/client';
export interface ImageGenerationRequest {
prompt: string;
@@ -87,9 +87,9 @@ export async function fetchPromptSuggestions(payload: {
research?: any;
persona?: any;
}): Promise<PromptSuggestion[]> {
// Use apiClient directly (same pattern as SEO analysis in SEOAnalysisModal.tsx)
// The apiClient interceptor will handle auth token injection automatically
const response = await apiClient.post('/api/images/suggest-prompts', payload);
// Use aiApiClient (3-minute timeout) because suggest-prompts calls an LLM
// which can take 30-60+ seconds to respond via WaveSpeed
const response = await aiApiClient.post('/api/images/suggest-prompts', payload);
return response.data.suggestions || [];
}

View File

@@ -12,7 +12,8 @@ import {
Stack,
CircularProgress,
Card,
CardContent
CardContent,
LinearProgress,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
@@ -24,6 +25,11 @@ import {
SkipNext as SkipIcon,
NavigateNext,
Psychology as AgentIcon,
TrendingUp as TrendUpIcon,
TrendingDown as TrendDownIcon,
TrendingFlat as TrendFlatIcon,
GpsFixed as GapIcon,
BarChart as VolumeIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useWorkflowStore } from '../../../stores/workflowStore';
@@ -155,7 +161,78 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
const isLastPillar = currentPillarIndex === pillarOrder.length - 1;
const nextPillarId = !isLastPillar ? pillarOrder[currentPillarIndex + 1] : null;
const getTaskStatus = (task: TodayTask) => {
const MetricBar = ({ label, value, color }: { label: string; value: number; color: string }) => (
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" sx={{ color: '#888', fontWeight: 600 }}>{label}</Typography>
<Typography variant="caption" sx={{ color, fontWeight: 700 }}>{(value * 100).toFixed(0)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={value * 100}
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e8e8e8',
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
}}
/>
</Box>
);
const GapScoringBreakdown = ({ scoring }: { scoring: Record<string, number> }) => {
const roi = scoring.roi_score ?? scoring.roi ?? 0;
const roiColor = roi >= 0.6 ? '#2e7d32' : roi >= 0.3 ? '#f57c00' : '#9e9e9e';
return (
<Box sx={{ mt: 2, p: 1.5, bgcolor: '#f5f7fa', borderRadius: 2, border: '1px solid #e0e4e8' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<GapIcon sx={{ fontSize: 18, color: roiColor }} />
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#333', flexGrow: 1 }}>
Opportunity Score
</Typography>
<Chip
label={`${(roi * 100).toFixed(0)}% ROI`}
size="small"
sx={{
fontWeight: 800,
bgcolor: `${roiColor}18`,
color: roiColor,
border: `1px solid ${roiColor}40`,
}}
/>
</Box>
<MetricBar label="Gap Size" value={scoring.gap_size ?? 0} color="#1565C0" />
<MetricBar label="Search Volume" value={scoring.volume ?? 0} color="#7b1fa2" />
<MetricBar label="Competition" value={scoring.competition ?? 0} color="#c62828" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
{(() => {
const t = scoring.trend ?? 0.5;
const Icon = t > 0.6 ? TrendUpIcon : t < 0.4 ? TrendDownIcon : TrendFlatIcon;
const tColor = t > 0.6 ? '#2e7d32' : t < 0.4 ? '#c62828' : '#f57c00';
return <Icon sx={{ fontSize: 16, color: tColor }} />;
})()}
<Typography variant="caption" sx={{ color: '#888', fontWeight: 600 }}>
Trend: {(scoring.trend ?? 0.5) >= 0.6 ? 'Rising' : (scoring.trend ?? 0.5) <= 0.4 ? 'Declining' : 'Stable'}
</Typography>
<Chip
label={scoring.intent && scoring.intent >= 0.7 ? 'Commercial' : scoring.intent && scoring.intent >= 0.5 ? 'Transactional' : 'Informational'}
size="small"
sx={{
ml: 'auto',
height: 20,
fontSize: '0.65rem',
fontWeight: 700,
bgcolor: '#e3f2fd',
color: '#1565C0',
}}
/>
</Box>
</Box>
);
};
const getTaskStatus = (task: TodayTask) => {
if (task.status === 'completed') return 'completed';
if (task.status === 'in_progress') return 'active';
if (task.status === 'skipped') return 'skipped';
@@ -367,7 +444,7 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
gap: 1.5
}}>
<AgentIcon sx={{ fontSize: 16, color: pillarColor, mt: 0.3 }} />
<Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#444' }}>
Suggested by {task.metadata.source_agent.replace('Agent', '')}
@@ -378,6 +455,10 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
"{task.metadata.reasoning}"
</Typography>
)}
{/* Gap scoring breakdown for ContentGapRadarAgent tasks */}
{task.metadata.source_agent === 'ContentGapRadarAgent' && task.metadata.context_data?.gap?.scoring && (
<GapScoringBreakdown scoring={task.metadata.context_data.gap.scoring} />
)}
</Box>
</Box>
)}

View File

@@ -19,8 +19,8 @@ import {
// import OnboardingButton from '../common/OnboardingButton';
import { useNavigate } from 'react-router-dom';
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components';
import { FinalStepProps, OnboardingData, Capability } from './types';
import { SetupSummary, CapabilitiesOverview, AgentTeamSection, TaskSchedulingPanel } from './components';
import { FinalStepProps, OnboardingData, Capability, OnboardingCompletionResult } from './types';
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam';
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
@@ -35,6 +35,8 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
const [agentTeam, setAgentTeam] = useState<AgentTeamCatalogEntry[]>([]);
const [agentTeamError, setAgentTeamError] = useState<string | null>(null);
const [completionResult, setCompletionResult] = useState<OnboardingCompletionResult | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
// const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -47,6 +49,25 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateHeaderContent]);
// Auto-redirect countdown after successful onboarding completion
useEffect(() => {
if (completionResult && countdown === null) {
setCountdown(8);
}
if (countdown === null || countdown <= 0) return;
const timer = setTimeout(() => {
setCountdown(prev => {
const next = (prev ?? 0) - 1;
if (next <= 0) {
navigate('/dashboard', { replace: true });
return 0;
}
return next;
});
}, 1000);
return () => clearTimeout(timer);
}, [completionResult, countdown, navigate]);
// Remove the DOM manipulation approach - we'll use React's built-in event handling
const loadOnboardingData = async () => {
@@ -300,9 +321,16 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
localStorage.setItem('onboarding_active_step', String(stepsLengthFallback()));
} catch {}
// Navigate directly to dashboard using React Router
console.log('FinalStep: Navigating to dashboard with react-router navigate("/dashboard")');
navigate('/dashboard', { replace: true });
// Show TaskSchedulingPanel with completion result (auto-redirect starts)
const typedResult: OnboardingCompletionResult = {
message: completionResult?.message || 'Onboarding completed successfully',
completed_at: completionResult?.completed_at || new Date().toISOString(),
completion_percentage: completionResult?.completion_percentage ?? 100,
persona_generated: completionResult?.persona_generated ?? false,
scheduled_tasks: completionResult?.scheduled_tasks || [],
failed_tasks: completionResult?.failed_tasks || null,
};
setCompletionResult(typedResult);
} catch (e: any) {
console.error('FinalStep: Error completing onboarding:', e);
console.error('FinalStep: Error details:', {
@@ -411,113 +439,157 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
{/* Content - Only show when data is loaded */}
{!dataLoading && (
<React.Fragment>
{/* Setup Summary */}
<SetupSummary
onboardingData={onboardingData}
capabilities={capabilities}
expandedSection={expandedSection}
setExpandedSection={setExpandedSection}
/>
{/* Post-completion: show TaskSchedulingPanel and hide setup details */}
{completionResult ? (
<React.Fragment>
<TaskSchedulingPanel
scheduledTasks={completionResult.scheduled_tasks}
failedTasks={completionResult.failed_tasks || []}
personaGenerated={completionResult.persona_generated}
completedAt={completionResult.completed_at}
/>
{/* Capabilities Overview */}
<CapabilitiesOverview capabilities={capabilities} />
{/* Agent Team */}
{agentTeamError && (
<Alert severity="warning" sx={{ mt: 3, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Agent team configuration unavailable
</Typography>
<Typography variant="body2">{agentTeamError}</Typography>
</Alert>
)}
{!agentTeamError && agentTeam.length > 0 && (
<AgentTeamSection websiteName={websiteName} agents={agentTeam} contextCard={agentContextCard} />
)}
{/* Missing Requirements Warning */}
{missingRequirements.length > 0 && (
<Zoom in={true} timeout={1400}>
<Alert
severity="warning"
sx={{ mb: 4, borderRadius: 2 }}
action={
<Button color="inherit" size="small">
Configure Now
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Missing Requirements
</Typography>
<Typography variant="body2">
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
</Typography>
</Alert>
</Zoom>
)}
{/* Alerts */}
<Box sx={{ mt: 3 }}>
{error && (
<Fade in={true}>
<Alert
severity="error"
sx={{ mb: 2, borderRadius: 2 }}
action={
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
color="inherit"
size="small"
onClick={() => setError(null)}
>
Dismiss
</Button>
</Box>
}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, gap: 2 }}>
<Button
variant="contained"
size="large"
startIcon={<Rocket />}
onClick={() => navigate('/dashboard', { replace: true })}
sx={{
background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
fontSize: '1.125rem',
fontWeight: 600,
px: 4,
py: 2,
borderRadius: 999,
textTransform: 'none',
boxShadow: '0 10px 28px rgba(15,23,42,0.45)',
letterSpacing: 0.2,
'&:hover': {
background: 'linear-gradient(135deg, #020617 0%, #1e1b4b 40%, #4338ca 100%)',
transform: 'translateY(-1px)',
boxShadow: '0 14px 36px rgba(15,23,42,0.55)',
},
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2">
{error}
Go to Dashboard
</Button>
</Box>
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Auto-redirecting to dashboard in {countdown ?? 0}s...
</Typography>
</Box>
</React.Fragment>
) : (
<React.Fragment>
{/* Setup Summary */}
<SetupSummary
onboardingData={onboardingData}
capabilities={capabilities}
expandedSection={expandedSection}
setExpandedSection={setExpandedSection}
/>
{/* Capabilities Overview */}
<CapabilitiesOverview capabilities={capabilities} />
{/* Agent Team */}
{agentTeamError && (
<Alert severity="warning" sx={{ mt: 3, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Agent team configuration unavailable
</Typography>
<Typography variant="body2">{agentTeamError}</Typography>
</Alert>
</Fade>
)}
</Box>
)}
{!agentTeamError && agentTeam.length > 0 && (
<AgentTeamSection websiteName={websiteName} agents={agentTeam} contextCard={agentContextCard} />
)}
{/* Validation Status */}
{validationStatus && !validationStatus.isValid && (
<Box sx={{ mb: 3 }}>
<Alert severity="warning" sx={{ borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
The following steps need to be completed before launching:
</Typography>
<Box component="ul" sx={{ pl: 2, m: 0 }}>
{validationStatus.missingSteps.map((step, index) => (
<li key={index}>
<Typography variant="body2">{step}</Typography>
</li>
))}
{/* Missing Requirements Warning */}
{missingRequirements.length > 0 && (
<Zoom in={true} timeout={1400}>
<Alert
severity="warning"
sx={{ mb: 4, borderRadius: 2 }}
action={
<Button color="inherit" size="small">
Configure Now
</Button>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Missing Requirements
</Typography>
<Typography variant="body2">
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
</Typography>
</Alert>
</Zoom>
)}
{/* Alerts */}
<Box sx={{ mt: 3 }}>
{error && (
<Fade in={true}>
<Alert
severity="error"
sx={{ mb: 2, borderRadius: 2 }}
action={
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
color="inherit"
size="small"
onClick={() => setError(null)}
>
Dismiss
</Button>
</Box>
}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
</Fade>
)}
</Box>
{/* Validation Status */}
{validationStatus && !validationStatus.isValid && (
<Box sx={{ mb: 3 }}>
<Alert severity="warning" sx={{ borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Setup Incomplete
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
The following steps need to be completed before launching:
</Typography>
<Box component="ul" sx={{ pl: 2, m: 0 }}>
{validationStatus.missingSteps.map((step, index) => (
<li key={index}>
<Typography variant="body2">{step}</Typography>
</li>
))}
</Box>
</Alert>
</Box>
</Alert>
</Box>
)}
)}
{/* Launch Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Button
variant="contained"
size="large"
disabled={loading || dataLoading}
onClick={handleLaunch}
startIcon={<Rocket />}
sx={{
{/* Launch Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Button
variant="contained"
size="large"
disabled={loading || dataLoading}
onClick={handleLaunch}
startIcon={<Rocket />}
sx={{
background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
fontSize: '1.125rem',
fontWeight: 600,
@@ -539,25 +611,27 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
transform: 'none',
}
}}
>
Launch Alwrity & Complete Setup
</Button>
</Box>
>
Launch Alwrity & Complete Setup
</Button>
</Box>
{/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
This will complete your onboarding and launch Alwrity with your configured settings.
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
>
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
Your SIF Agent Framework is ready to orchestrate your marketing.
</Typography>
</Box>
{/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
This will complete your onboarding and launch Alwrity with your configured settings.
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
>
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
Your SIF Agent Framework is ready to orchestrate your marketing.
</Typography>
</Box>
</React.Fragment>
)}
</React.Fragment>
)}
</Container>

View File

@@ -0,0 +1,273 @@
import React from 'react';
import {
Box,
Paper,
Zoom,
Typography,
Chip,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
IconButton,
Alert,
} from '@mui/material';
import {
CheckCircle,
ErrorOutline,
ExpandMore,
ExpandLess,
RocketLaunch,
Autorenew,
} from '@mui/icons-material';
export interface ScheduledTask {
task: string;
error?: string;
}
export interface TaskSchedulingPanelProps {
scheduledTasks: string[];
failedTasks: ScheduledTask[];
personaGenerated: boolean;
completedAt: string | null;
}
const TASK_LABELS: Record<string, string> = {
research_persona: 'Research Persona Generation',
facebook_persona: 'Facebook Persona Generation',
oauth_monitoring: 'OAuth Token Monitoring',
website_analysis: 'Website Analysis',
full_site_seo_audit: 'Full-Site SEO Audit',
sif_indexing: 'SIF Indexing',
market_trends: 'Market Trends',
deep_competitor_analysis: 'Deep Competitor Analysis',
market_trends_no_url: 'Market Trends (no website)',
progressive_setup: 'User Environment Setup',
};
function getTaskLabel(key: string): string {
return TASK_LABELS[key] || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function getTaskType(key: string): 'oneshot' | 'recurring' | 'setup' | 'unknown' {
if (['research_persona', 'facebook_persona', 'website_analysis'].includes(key)) return 'oneshot';
if (['full_site_seo_audit', 'sif_indexing', 'market_trends', 'market_trends_no_url', 'deep_competitor_analysis', 'oauth_monitoring'].includes(key)) return 'recurring';
if (key === 'progressive_setup') return 'setup';
return 'unknown';
}
function getTaskTypeChip(type: 'oneshot' | 'recurring' | 'setup' | 'unknown') {
const config = {
oneshot: { label: 'One-time', color: '#3b82f6' as const },
recurring: { label: 'Recurring', color: '#8b5cf6' as const },
setup: { label: 'Setup', color: '#10b981' as const },
unknown: { label: 'Task', color: '#6b7280' as const },
};
const c = config[type];
return <Chip label={c.label} size="small" sx={{ bgcolor: `${c.color}18`, color: c.color, fontWeight: 600, fontSize: '0.7rem' }} />;
}
function formatCompletedAt(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
export const TaskSchedulingPanel: React.FC<TaskSchedulingPanelProps> = ({
scheduledTasks,
failedTasks,
personaGenerated,
completedAt,
}) => {
const [showDetails, setShowDetails] = React.useState(false);
const totalTasks = scheduledTasks.length + failedTasks.length;
const successRate = totalTasks > 0 ? Math.round((scheduledTasks.length / totalTasks) * 100) : 100;
return (
<Zoom in={true} timeout={800}>
<Paper elevation={0} sx={{
p: 4,
mb: 4,
background: failedTasks.length > 0
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
border: failedTasks.length > 0
? '1px solid rgba(245, 158, 11, 0.3)'
: '1px solid rgba(16, 185, 129, 0.25)',
borderRadius: 3,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3, flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{failedTasks.length > 0 ? (
<Autorenew sx={{ color: 'warning.main', fontSize: 30 }} />
) : (
<RocketLaunch sx={{ color: 'success.main', fontSize: 30 }} />
)}
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a' }}>
{failedTasks.length > 0 ? 'Setup Tasks Scheduled' : 'All Tasks Launched!'}
</Typography>
<Typography variant="body2" sx={{ color: '#475569' }}>
{completedAt ? `Completed at ${formatCompletedAt(completedAt)}` : 'Onboarding complete'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Chip
icon={<CheckCircle sx={{ fontSize: 16 }} />}
label={`${scheduledTasks.length} Scheduled`}
color="success"
variant="filled"
size="small"
/>
{failedTasks.length > 0 && (
<Chip
icon={<ErrorOutline sx={{ fontSize: 16 }} />}
label={`${failedTasks.length} Failed`}
color="warning"
variant="filled"
size="small"
/>
)}
<Chip
label={`${successRate}% Success`}
sx={{
bgcolor: successRate === 100 ? '#ecfdf5' : '#fef3c7',
color: successRate === 100 ? '#059669' : '#d97706',
fontWeight: 600,
}}
size="small"
/>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600 }}>Task Scheduling Progress</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>{scheduledTasks.length}/{totalTasks}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={successRate}
sx={{
height: 8,
borderRadius: 4,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
borderRadius: 4,
bgcolor: successRate === 100 ? '#10b981' : '#f59e0b',
},
}}
/>
</Box>
{personaGenerated && (
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
AI Persona Generated
</Typography>
<Typography variant="body2">
Your brand persona was generated during setup. Your agents will use this for personalized content.
</Typography>
</Alert>
)}
{!personaGenerated && (
<Alert severity="info" sx={{ mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Persona Generation Scheduled
</Typography>
<Typography variant="body2">
Your brand persona is being generated in the background. This typically takes 5-10 minutes after launch.
</Typography>
</Alert>
)}
{failedTasks.length > 0 && (
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Some Tasks Could Not Be Scheduled
</Typography>
<Typography variant="body2">
{failedTasks.length} task(s) failed to schedule. These will be retried automatically by the scheduler. You can also retry from the Team Activity page.
</Typography>
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<IconButton
size="small"
onClick={() => setShowDetails(!showDetails)}
sx={{ color: '#475569' }}
>
<Typography variant="caption" sx={{ fontWeight: 600, mr: 0.5 }}>
{showDetails ? 'Hide' : 'Show'} task details
</Typography>
{showDetails ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Collapse in={showDetails}>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, color: '#0f172a' }}>
Scheduled Tasks ({scheduledTasks.length})
</Typography>
{scheduledTasks.length === 0 ? (
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic', pl: 2 }}>
No tasks were scheduled.
</Typography>
) : (
<List dense sx={{ bgcolor: 'rgba(255,255,255,0.6)', borderRadius: 2, mb: 2 }}>
{scheduledTasks.map((taskKey) => (
<ListItem key={taskKey} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircle sx={{ color: '#10b981', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary={getTaskLabel(taskKey)}
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
/>
{getTaskTypeChip(getTaskType(taskKey))}
</ListItem>
))}
</List>
)}
{failedTasks.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, color: '#92400e' }}>
Failed Tasks ({failedTasks.length})
</Typography>
<List dense sx={{ bgcolor: 'rgba(254,243,199,0.5)', borderRadius: 2 }}>
{failedTasks.map((ft, idx) => (
<ListItem key={idx} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<ErrorOutline sx={{ color: '#d97706', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary={getTaskLabel(ft.task)}
secondary={ft.error || 'Unknown error'}
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#92400e' }}
secondaryTypographyProps={{ variant: 'caption', color: '#78716c' }}
/>
{getTaskTypeChip(getTaskType(ft.task))}
</ListItem>
))}
</List>
</>
)}
</Box>
</Collapse>
</Paper>
</Zoom>
);
};
export default TaskSchedulingPanel;

View File

@@ -1,4 +1,6 @@
export { default as SetupSummary } from './SetupSummary';
export { default as CapabilitiesOverview } from './CapabilitiesOverview';
export { default as AgentTeamSection } from './AgentTeamSection';
export { default as TaskSchedulingPanel } from './TaskSchedulingPanel';
export type { TaskSchedulingPanelProps, ScheduledTask } from './TaskSchedulingPanel';

View File

@@ -22,3 +22,12 @@ export interface FinalStepProps {
onContinue: () => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
}
export interface OnboardingCompletionResult {
message: string;
completed_at: string;
completion_percentage: number;
persona_generated: boolean;
scheduled_tasks: string[];
failed_tasks: Array<{ task: string; error: string }> | null;
}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react';
import { createClient, OAuthStrategy } from '@wix/sdk';
import { WIX_CLIENT_ID, getWixRedirectOrigin, getWixTrustedOrigins } from '../../../config/wixConfig';
import { markConnectionHandled, isAlreadyHandled, clearConnectionHandled } from '../../../utils/wixConnectionDedup';
export const usePlatformConnections = () => {
const [connectedPlatforms, setConnectedPlatforms] = useState<string[]>([]);
@@ -7,15 +9,15 @@ export const usePlatformConnections = () => {
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
// Handle Wix OAuth popup messages
useEffect(() => {
const handler = (event: MessageEvent) => {
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const trusted = [window.location.origin, ngrokOrigin];
const trusted = getWixTrustedOrigins();
if (!trusted.includes(event.origin)) return;
if (!event.data || typeof event.data !== 'object') return;
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
if (isAlreadyHandled()) return;
markConnectionHandled();
setConnectedPlatforms(prev => {
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
return updated;
@@ -36,6 +38,8 @@ export const usePlatformConnections = () => {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('wix_connected') === 'true') {
if (isAlreadyHandled()) return;
markConnectionHandled();
setConnectedPlatforms(prev => {
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
return updated;
@@ -47,6 +51,7 @@ export const usePlatformConnections = () => {
const handleWixConnect = async () => {
try {
clearConnectionHandled();
// Store current page URL BEFORE redirecting (critical for proper redirect back)
// This ensures we can redirect back to the correct page (e.g., Blog Writer) after OAuth
// Only store if not already set (allows WixConnectModal to override if needed)
@@ -60,22 +65,25 @@ export const usePlatformConnections = () => {
// Ignore storage errors
}
if (!WIX_CLIENT_ID) {
throw new Error('WIX_CLIENT_ID is not configured. Please check your .env file and restart the dev server.');
}
console.log('[handleWixConnect] Using WIX_CLIENT_ID:', WIX_CLIENT_ID.substring(0, 8) + '...');
// Use the working Wix OAuth flow from WixTestPage
const wixClient = createClient({
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID })
});
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
const redirectOrigin = getWixRedirectOrigin();
const redirectUri = `${redirectOrigin}/wix/callback`;
console.log('[handleWixConnect] Redirect URI:', redirectUri);
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
// Persist OAuth data robustly so callback can always recover it
// 1) SessionStorage for same-origin same-tab flows
try { sessionStorage.setItem('wix_oauth_data', JSON.stringify(oauthData)); } catch {}
// 2) Key by state so callback can look up by state value
try { sessionStorage.setItem(`wix_oauth_data_${oauthData.state}`, JSON.stringify(oauthData)); } catch {}
// 3) window.name persists across top-level redirects even when origin changes
try {
const redirectTo = sessionStorage.getItem('wix_oauth_redirect') || window.location.href;
console.log('[handleWixConnect] Storing redirect_to in window.name:', redirectTo);
@@ -83,10 +91,23 @@ export const usePlatformConnections = () => {
} catch (e) {
console.error('[handleWixConnect] Failed to set window.name:', e);
}
console.log('[handleWixConnect] Generating auth URL...');
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
console.log('[handleWixConnect] Auth URL generated, redirecting...');
window.location.href = authUrl;
} catch (error) {
} catch (error: any) {
console.error('Wix connection error:', error);
const message = error?.message || 'Unknown error during Wix connection';
if (message.includes('System error occurred')) {
throw new Error(
`Wix SDK failed to generate auth URL. Common causes:\n` +
`1. WIX_CLIENT_ID is missing or invalid (current: ${WIX_CLIENT_ID ? 'set' : 'EMPTY'})\n` +
`2. The redirect URI (${getWixRedirectOrigin()}/wix/callback) is not registered in your Wix app\n` +
`3. The Wix app does not have OAuth enabled\n` +
`Original error: ${message}`
);
}
throw error;
}
};

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import {
Box,
Container,
@@ -69,6 +70,7 @@ import { AdvertoolsInsights } from './components/AdvertoolsInsights';
import SemanticHealthCard from './components/SemanticHealthCard';
import SemanticInsights from './components/SemanticInsights';
import KeywordGapAnalysis from './components/KeywordGapAnalysis';
import ContentGapRadarCard from './components/ContentGapRadarCard';
// Phase 2A: Enterprise SEO Analysis
import SEOAnalysisController from './SEOAnalysisController';
@@ -118,7 +120,19 @@ const SEODashboard: React.FC = () => {
// Dashboard Tab State for Enterprise Analysis
const [dashboardTab, setDashboardTab] = useState<number>(0);
const location = useLocation();
// Hash-based deep-link scroll (e.g. #content-gap-radar from workflow tasks)
useEffect(() => {
if (location.hash) {
const id = location.hash.replace('#', '');
const el = document.getElementById(id);
if (el) {
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
}
}
}, [location.hash]);
// Competitor analysis data from onboarding step 3
const [competitorAnalysisData, setCompetitorAnalysisData] = useState<any>(null);
const [deepCompetitorAnalysisData, setDeepCompetitorAnalysisData] = useState<any>(null);
@@ -933,6 +947,11 @@ const SEODashboard: React.FC = () => {
{/* Keyword Gap Analysis */}
<KeywordGapAnalysis />
{/* Content Gap Radar */}
<Box id="content-gap-radar">
<ContentGapRadarCard />
</Box>
{/* Full Site Technical SEO Audit (from onboarding background job) */}
{data.technical_seo_audit && (
<Box sx={{ mb: 4 }}>

View File

@@ -0,0 +1,493 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box,
Typography,
Chip,
CircularProgress,
Tooltip,
Accordion,
AccordionSummary,
AccordionDetails,
LinearProgress,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Divider,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Refresh as RefreshIcon,
Explore as ExploreIcon,
TrendingUp as TrendingUpIcon,
Search as SearchIcon,
Store as StoreIcon,
Speed as SpeedIcon,
Flag as FlagIcon,
AutoAwesome as AutoAwesomeIcon,
ContentCopy as ContentCopyIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { GlassCard } from '../../shared/styled';
import { apiClient } from '../../../api/client';
interface ScoringBreakdown {
gap_size: number;
volume: number;
trend: number;
intent: number;
competition: number;
}
interface GapItem {
topic: string;
roi_score: number;
priority: 'high' | 'medium' | 'low';
recommended_action: string;
scoring: ScoringBreakdown;
sif_gap?: any;
serp_evidence?: {
competitors_found: Array<{ domain: string; title: string; url: string; snippet: string }>;
competitor_count: number;
domains_with_content: string[];
} | null;
competitor_content?: any;
}
interface GapRadarData {
gaps: GapItem[];
summary: {
total_topics_analyzed: number;
high_priority: number;
medium_priority: number;
low_priority: number;
};
error?: string;
message?: string;
}
interface ContentBrief {
titles: string[];
outline: Array<{ heading: string; key_points: string[] }>;
keywords: string[];
angle: string;
word_count: number;
}
const priorityColor = (p: string): string => {
switch (p) {
case 'high': return '#ef4444';
case 'medium': return '#f59e0b';
default: return '#22c55e';
}
};
const roiBarColor = (score: number): string => {
if (score >= 0.6) return '#22c55e';
if (score >= 0.3) return '#f59e0b';
return '#ef4444';
};
const roiLabel = (score: number): string => {
if (score >= 0.6) return 'High Opportunity';
if (score >= 0.3) return 'Moderate Opportunity';
return 'Low Priority';
};
const scoringConfig = [
{ key: 'gap_size', label: 'Gap Size', icon: <SearchIcon sx={{ fontSize: 14 }} />, color: '#90CAF9' },
{ key: 'volume', label: 'Search Volume', icon: <TrendingUpIcon sx={{ fontSize: 14 }} />, color: '#22c55e' },
{ key: 'trend', label: 'Trend Momentum', icon: <SpeedIcon sx={{ fontSize: 14 }} />, color: '#f59e0b' },
{ key: 'intent', label: 'Intent Score', icon: <FlagIcon sx={{ fontSize: 14 }} />, color: '#CE93D8' },
{ key: 'competition', label: 'Competition', icon: <StoreIcon sx={{ fontSize: 14 }} />, color: '#ef4444' },
];
const ContentGapRadarCard: React.FC = () => {
const navigate = useNavigate();
const [data, setData] = useState<GapRadarData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generatingTopic, setGeneratingTopic] = useState<string | null>(null);
const [briefResult, setBriefResult] = useState<{ brief: ContentBrief; asset_id: number | null } | null>(null);
const fetchData = useCallback(async (bypassCache = false) => {
try {
setLoading(true);
setError(null);
const params: any = {};
if (bypassCache) params.bypass_cache = 'true';
const resp = await apiClient.get('/api/seo-dashboard/content-gap-radar', { params });
setData(resp.data);
if (resp.data.error) setError(resp.data.error);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to load content gap radar');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const generateContent = useCallback(async (gap: GapItem) => {
try {
setGeneratingTopic(gap.topic);
setBriefResult(null);
const resp = await apiClient.post('/api/seo-dashboard/content-gap-radar/generate-content', {
topic: gap.topic,
recommended_action: gap.recommended_action,
scoring: gap.scoring,
serp_evidence: gap.serp_evidence,
sif_gap: gap.sif_gap,
});
setBriefResult(resp.data);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to generate content brief');
} finally {
setGeneratingTopic(null);
}
}, []);
const handleOpenBlogWriter = useCallback(() => {
if (briefResult?.asset_id) {
navigate('/blog-writer', { state: { restoreBlogAssetId: briefResult.asset_id } });
} else {
navigate('/blog-writer');
}
}, [briefResult, navigate]);
const handleCopyBrief = useCallback(() => {
if (!briefResult?.brief) return;
const b = briefResult.brief;
const text = [
`## Content Brief\n`,
`### Titles\n${b.titles.map((t, i) => `${i + 1}. ${t}`).join('\n')}\n`,
`### Angle\n${b.angle}\n`,
`### Keywords\n${b.keywords.join(', ')}\n`,
`### Outline\n${b.outline.map(s => `- ${s.heading}\n${s.key_points.map(kp => ` - ${kp}`).join('\n')}`).join('\n')}`,
`\nTarget: ~${b.word_count} words`,
].join('\n');
navigator.clipboard.writeText(text);
}, [briefResult]);
if (loading && !data) {
return (
<GlassCard sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 1, display: 'block' }}>
Scanning content gaps...
</Typography>
</GlassCard>
);
}
const hasGaps = data?.gaps && data.gaps.length > 0;
const summary = data?.summary;
return (
<Box sx={{ mb: 4 }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<ExploreIcon sx={{ color: 'white', fontSize: 20 }} />
<Typography variant="h6" fontWeight={700} sx={{ color: 'white', flex: 1 }}>
Content Gap Radar
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => fetchData(true)}
disabled={loading}
sx={{
color: 'rgba(255,255,255,0.7)',
borderColor: 'rgba(255,255,255,0.2)',
fontSize: '0.7rem',
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
{loading ? 'Scanning...' : 'Refresh'}
</Button>
</Box>
{error && !hasGaps && (
<GlassCard sx={{ p: 3, mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)' }}>{error}</Typography>
</GlassCard>
)}
{data?.message && !hasGaps && (
<GlassCard sx={{ p: 3, mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>{data.message}</Typography>
</GlassCard>
)}
{loading && hasGaps && (
<LinearProgress sx={{ mb: 2, borderRadius: 1, bgcolor: 'rgba(255,255,255,0.05)', '& .MuiLinearProgress-bar': { bgcolor: '#2196F3' } }} />
)}
{summary && summary.total_topics_analyzed > 0 && (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1.5, mb: 2 }}>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Topics Analyzed</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: 'white' }}>{summary.total_topics_analyzed}</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>High Priority</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: '#ef4444' }}>{summary.high_priority}</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Medium Priority</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: '#f59e0b' }}>{summary.medium_priority}</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Low Priority</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: '#22c55e' }}>{summary.low_priority}</Typography>
</GlassCard>
</Box>
)}
{hasGaps && data!.gaps.map((gap, i) => (
<Accordion
key={gap.topic}
defaultExpanded={i === 0}
disableGutters
elevation={0}
sx={{
bgcolor: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '8px !important',
mb: 1,
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.5)' }} />}>
<Box sx={{ width: '100%', mr: 1 }}>
<Box display="flex" alignItems="center" gap={1.5} mb={0.5}>
<Typography variant="subtitle2" fontWeight={600} sx={{ color: 'white', flex: 1 }}>
{gap.topic}
</Typography>
<Chip
label={gap.priority}
size="small"
sx={{
height: 18, fontSize: '0.6rem', fontWeight: 700,
bgcolor: `${priorityColor(gap.priority)}22`,
color: priorityColor(gap.priority),
}}
/>
<Chip
label={`ROI ${(gap.roi_score * 100).toFixed(0)}%`}
size="small"
sx={{
height: 18, fontSize: '0.6rem', fontWeight: 700,
bgcolor: `${roiBarColor(gap.roi_score)}22`,
color: roiBarColor(gap.roi_score),
}}
/>
<Button
size="small"
variant="outlined"
startIcon={generatingTopic === gap.topic ? <CircularProgress size={12} /> : <AutoAwesomeIcon />}
onClick={(e) => { e.stopPropagation(); generateContent(gap); }}
disabled={generatingTopic !== null}
sx={{
height: 24, minWidth: 0, px: 1, fontSize: '0.6rem', fontWeight: 600,
color: '#CE93D8',
borderColor: 'rgba(206,147,216,0.3)',
whiteSpace: 'nowrap',
'&:hover': { borderColor: '#CE93D8', bgcolor: 'rgba(206,147,216,0.08)' },
}}
>
{generatingTopic === gap.topic ? 'Generating...' : 'Create Content'}
</Button>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Box sx={{ flex: 1, height: 4, bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ width: `${Math.min(gap.roi_score * 100, 100)}%`, height: '100%', bgcolor: roiBarColor(gap.roi_score), borderRadius: 2 }} />
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0, fontSize: '0.6rem' }}>
{roiLabel(gap.roi_score)}
</Typography>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', display: 'block', mb: 1.5, fontStyle: 'italic' }}>
{gap.recommended_action}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block' }}>
Scoring Breakdown
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1, mb: 1.5 }}>
{scoringConfig.map((s) => {
const val = (gap.scoring as any)[s.key] ?? 0;
return (
<Box key={s.key} sx={{ textAlign: 'center', p: 0.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
<Box display="flex" justifyContent="center" alignItems="center" gap={0.3} mb={0.3}>
{s.icon}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.55rem' }}>{s.label}</Typography>
</Box>
<Typography variant="body2" fontWeight={700} sx={{ color: s.color, fontSize: '0.8rem' }}>
{(val * 100).toFixed(0)}%
</Typography>
<Box sx={{ height: 2, bgcolor: 'rgba(255,255,255,0.08)', borderRadius: 1, mt: 0.3, overflow: 'hidden' }}>
<Box sx={{ width: `${val * 100}%`, height: '100%', bgcolor: s.color, borderRadius: 1 }} />
</Box>
</Box>
);
})}
</Box>
{gap.serp_evidence && gap.serp_evidence.competitors_found?.length > 0 && (
<>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block' }}>
Competitors Ranking {gap.serp_evidence.competitor_count} results across {gap.serp_evidence.domains_with_content?.length || 0} domains
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
{gap.serp_evidence.domains_with_content?.slice(0, 5).map((d: string) => (
<Chip key={d} label={d} size="small" sx={{ height: 18, fontSize: '0.55rem', bgcolor: 'rgba(33,150,243,0.12)', color: '#90CAF9' }} />
))}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1 }}>
{gap.serp_evidence.competitors_found.slice(0, 3).map((c: any, ci: number) => (
<Box key={ci} sx={{ p: 0.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
<Typography variant="caption" fontWeight={600} sx={{ color: 'rgba(255,255,255,0.7)' }}>
{c.title || c.domain}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', display: 'block', fontSize: '0.55rem' }}>
{c.snippet?.slice(0, 120)}
</Typography>
</Box>
))}
</Box>
</>
)}
{gap.sif_gap && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', display: 'block' }}>
SIF gap: {gap.sif_gap.priority} priority &middot; confidence {((gap.sif_gap.confidence ?? 0) * 100).toFixed(0)}% &middot; delta {((gap.sif_gap.coverage_delta ?? 0) * 100).toFixed(1)}%
</Typography>
)}
</AccordionDetails>
</Accordion>
))}
{/* Content Brief Dialog */}
<Dialog
open={briefResult !== null}
onClose={() => setBriefResult(null)}
maxWidth="md"
fullWidth
PaperProps={{
sx: { bgcolor: '#1a1a2e', color: 'white', border: '1px solid rgba(255,255,255,0.1)' },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, pb: 1 }}>
<AutoAwesomeIcon sx={{ color: '#CE93D8', fontSize: 20 }} />
<Typography variant="h6" fontWeight={700} sx={{ flex: 1 }}>
Content Brief
</Typography>
<IconButton size="small" onClick={() => setBriefResult(null)} sx={{ color: 'rgba(255,255,255,0.5)' }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
{briefResult && (
<DialogContent sx={{ pt: 1 }}>
{/* Headline options */}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
HEADLINE OPTIONS
</Typography>
<Box sx={{ mb: 2 }}>
{briefResult.brief.titles.map((t, i) => (
<Typography key={i} variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.3 }}>
{i + 1}. {t}
</Typography>
))}
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
{/* Writing angle */}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
STRATEGIC ANGLE
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 2, lineHeight: 1.6 }}>
{briefResult.brief.angle}
</Typography>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
{/* Target keywords */}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
TARGET KEYWORDS
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{briefResult.brief.keywords.map((kw) => (
<Chip key={kw} label={kw} size="small" sx={{ height: 20, fontSize: '0.6rem', bgcolor: 'rgba(206,147,216,0.12)', color: '#CE93D8' }} />
))}
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
{/* Outline */}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
OUTLINE ~{briefResult.brief.word_count} words
</Typography>
{briefResult.brief.outline.map((section, i) => (
<Box key={i} sx={{ mb: 1.5, p: 1, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.5 }}>
{section.heading}
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{section.key_points.map((kp, j) => (
<Typography key={j} variant="caption" component="li" sx={{ color: 'rgba(255,255,255,0.5)', display: 'list-item', mb: 0.2 }}>
{kp}
</Typography>
))}
</Box>
</Box>
))}
</DialogContent>
)}
<DialogActions sx={{ p: 2, pt: 0, gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<ContentCopyIcon />}
onClick={handleCopyBrief}
sx={{
color: 'rgba(255,255,255,0.7)',
borderColor: 'rgba(255,255,255,0.2)',
fontSize: '0.75rem',
'&:hover': { borderColor: 'rgba(255,255,255,0.4)' },
}}
>
Copy Brief
</Button>
<Button
variant="contained"
size="small"
startIcon={<AutoAwesomeIcon />}
onClick={handleOpenBlogWriter}
sx={{
bgcolor: '#CE93D8',
color: '#1a1a2e',
fontSize: '0.75rem',
fontWeight: 700,
'&:hover': { bgcolor: '#BA68C8' },
}}
>
Open in Blog Writer
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ContentGapRadarCard;

View File

@@ -14,4 +14,5 @@ export { default as SEOAnalysisError } from './SEOAnalysisError';
export { default as PlatformStatus } from './PlatformStatus';
export { default as AIInsightsPanel } from './AIInsightsPanel';
export { default as MetricCard } from './MetricCard';
export { default as HealthScore } from './HealthScore';
export { default as HealthScore } from './HealthScore';
export { default as ContentGapRadarCard } from './ContentGapRadarCard';

View File

@@ -0,0 +1,161 @@
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
Chip,
Collapse,
ToggleButton,
ToggleButtonGroup,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Terminal as TerminalIcon,
} from '@mui/icons-material';
import { AgentRunItem, AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface ActivityLogProps {
runs: AgentRunItem[];
events: AgentEventItem[];
}
type Tab = 'runs' | 'events';
const ActivityLog: React.FC<ActivityLogProps> = ({ runs, events }) => {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<Tab>('runs');
const nonCommitteeEvents = useMemo(
() => events.filter((e) => e.event_type !== 'committee_meeting'),
[events],
);
return (
<Box
sx={{
background: 'rgba(255,255,255,0.04)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 3,
overflow: 'hidden',
}}
>
{/* Header */}
<Box
onClick={() => setOpen(!open)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1.25,
cursor: 'pointer',
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
}}
>
<TerminalIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)', fontWeight: 600, flex: 1, fontSize: 13 }}>
Activity Log
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, mr: 1 }}>
<Chip
label={`${runs.length} runs`}
size="small"
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.4)' }}
/>
<Chip
label={`${nonCommitteeEvents.length} events`}
size="small"
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.4)' }}
/>
</Box>
{open ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
<Collapse in={open}>
<Box sx={{ px: 2, pb: 1.5 }}>
{/* Tabs */}
<ToggleButtonGroup
size="small"
value={tab}
exclusive
onChange={(_, v) => v && setTab(v)}
sx={{
mb: 1,
'& .MuiToggleButton-root': {
fontSize: 11,
fontWeight: 600,
textTransform: 'none',
color: 'rgba(255,255,255,0.4)',
borderColor: 'rgba(255,255,255,0.1)',
px: 1.5,
py: 0.25,
'&.Mui-selected': {
color: '#8b9cf7',
bgcolor: 'rgba(102,126,234,0.15)',
borderColor: 'rgba(102,126,234,0.3)',
},
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
},
}}
>
<ToggleButton value="runs">Runs</ToggleButton>
<ToggleButton value="events">Events</ToggleButton>
</ToggleButtonGroup>
{/* Content */}
<Box
sx={{
maxHeight: 300,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2 },
}}
>
{tab === 'runs' && (
runs.length === 0
? <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', py: 2 }}>No runs recorded</Typography>
: runs.slice(0, 50).map((run) => (
<Box key={run.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 1, borderRadius: 1, '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' } }}>
<Box sx={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
bgcolor: run.status === 'completed' || run.success ? '#4caf50' : run.status === 'error' || run.success === false ? '#f44336' : run.status === 'running' ? '#2196f3' : '#9e9e9e',
}} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', minWidth: 100, fontSize: 10, fontWeight: 600 }}>
{run.agent_type || 'agent'}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', flex: 1, fontSize: 10 }}>
{run.status || '—'}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', fontSize: 9, minWidth: 50, textAlign: 'right' }}>
{run.finished_at ? new Date(run.finished_at).toLocaleTimeString() : run.started_at ? new Date(run.started_at).toLocaleTimeString() : '—'}
</Typography>
</Box>
))
)}
{tab === 'events' && (
nonCommitteeEvents.length === 0
? <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', py: 2 }}>No events recorded</Typography>
: nonCommitteeEvents.slice(0, 50).map((evt) => (
<Box key={evt.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 1, borderRadius: 1, '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' } }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', minWidth: 100, fontSize: 10, fontWeight: 600 }}>
{evt.agent_type || 'agent'}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', flex: 1, fontSize: 10 }}>
{evt.event_type}{evt.message ? `: ${evt.message}` : ''}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', fontSize: 9, minWidth: 50, textAlign: 'right' }}>
{evt.created_at ? new Date(evt.created_at).toLocaleTimeString() : '—'}
</Typography>
</Box>
))
)}
</Box>
</Box>
</Collapse>
</Box>
);
};
export default ActivityLog;

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
IconButton,
Tooltip,
Divider,
} from '@mui/material';
import {
HelpOutline as HelpIcon,
ExpandMore as ExpandMoreIcon,
Close as CloseIcon,
SmartToy as AgentIcon,
} from '@mui/icons-material';
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../api/agentsTeam';
const AGENT_DESCRIPTIONS: Record<string, { short: string; long: string }> = {
content_strategy: {
short: 'Orchestrates content pillars and strategy',
long: 'The Content Strategy Agent defines your content pillars, target keywords, and content calendar. It ensures alignment across all content pieces to maintain a cohesive brand narrative.',
},
strategy_architect: {
short: 'Builds strategic content plans',
long: 'The Strategy Architect develops long-term content strategies, identifies market positioning opportunities, and creates data-driven plans that align with business objectives.',
},
seo_optimization: {
short: 'Optimizes content for search engines',
long: 'The SEO Agent analyzes search trends, identifies keyword opportunities, and ensures your content is optimized for discoverability. It handles on-page SEO, meta tags, and internal linking strategies.',
},
social_amplification: {
short: 'Amplifies content across social channels',
long: 'The Social Amplification Agent creates platform-specific social media adaptations of your content, schedules posts for optimal engagement, and monitors social signals.',
},
competitor: {
short: 'Monitors competitor activity and strategy',
long: 'The Competitor Agent continuously tracks competitor content, identifies content gaps, and provides strategic intelligence on competitor positioning, keywords, and audience targeting.',
},
content_gap_radar: {
short: 'Detects content coverage gaps',
long: 'The Content Gap Radar Agent identifies topics and keywords where your content is underperforming or missing. It surfaces opportunities to capture audience interest that competitors are neglecting.',
},
trend_surfer: {
short: 'Surfaces trending opportunities',
long: 'The Trend Surfer Agent monitors real-time search trends, social signals, and market movement. It surfaces opportunities with urgency ratings, impact scores, and suggested angles for content creation.',
},
content_guardian: {
short: 'Quality watchdog over committee output',
long: 'The Content Guardian Agent audits the committee\'s output after each daily workflow. It checks reasoning quality, identifies coverage gaps, flags overlaps, and generates alerts for systemic issues. It never proposes tasks — only audits.',
},
};
const SIF_DESCRIPTION = {
short: 'Semantic Intelligence Framework — the orchestration layer',
long: 'The SIF (Semantic Intelligence Framework) is ALwrity\'s orchestration layer for autonomous marketing agents. It coordinates the 6-member committee (ContentStrategy, StrategyArchitect, SEO, Social, Competitor, ContentGapRadar), plus TrendSurfer for signal detection and ContentGuardian for quality auditing. The SIF handles prompt sequencing, context card assembly, and committee voting.',
};
const AgentHelpModal: React.FC = () => {
const [open, setOpen] = useState(false);
const [agents, setAgents] = useState<AgentTeamCatalogEntry[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setLoading(true);
getAgentTeam()
.then(setAgents)
.catch(() => setAgents([]))
.finally(() => setLoading(false));
}
}, [open]);
return (
<>
<Tooltip title="Learn about your AI agents" arrow>
<IconButton
size="small"
onClick={() => setOpen(true)}
sx={{ color: 'rgba(255,255,255,0.6)', '&:hover': { color: 'rgba(255,255,255,0.9)' } }}
>
<HelpIcon fontSize="small" />
</IconButton>
</Tooltip>
<Dialog
open={open}
onClose={() => setOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: '#1a1a2e',
color: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 3,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<AgentIcon sx={{ color: '#7c3aed' }} />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)' }}>
Your AI Marketing Team
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
Powered by the SIF Agent Framework
</Typography>
</Box>
</Box>
<IconButton size="small" onClick={() => setOpen(false)} sx={{ color: 'rgba(255,255,255,0.5)' }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<DialogContent sx={{ pt: 2 }}>
<Box sx={{ p: 2, mb: 2, borderRadius: 2, bgcolor: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.2)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#a78bfa', mb: 0.5 }}>
SIF Agent Framework
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', lineHeight: 1.5 }}>
{SIF_DESCRIPTION.long}
</Typography>
</Box>
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label="6 Committee Agents" size="small" sx={{ bgcolor: 'rgba(79,70,229,0.15)', color: '#818cf8', fontWeight: 600 }} />
<Chip label="1 Trend Agent" size="small" sx={{ bgcolor: 'rgba(255,152,0,0.15)', color: '#ffb74d', fontWeight: 600 }} />
<Chip label="1 Watchdog Agent" size="small" sx={{ bgcolor: 'rgba(76,175,80,0.15)', color: '#81c784', fontWeight: 600 }} />
</Box>
{loading && (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', py: 3 }}>
Loading agent details...
</Typography>
)}
{!loading && agents.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{agents.map((agent) => {
const desc = AGENT_DESCRIPTIONS[agent.agent_key];
const displayName = agent.profile?.display_name || agent.defaults?.display_name_template?.replace('{website_name}', 'Your') || agent.role || agent.agent_key;
const enabled = agent.profile?.enabled ?? agent.defaults?.enabled ?? true;
const schedule = agent.profile?.schedule?.mode || agent.defaults?.schedule?.mode || 'on_demand';
return (
<Accordion
key={agent.agent_key}
disableGutters
elevation={0}
sx={{
bgcolor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '8px !important',
'&:before': { display: 'none' },
'&.Mui-expanded': { bgcolor: 'rgba(255,255,255,0.06)', margin: 0 },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.3)' }} />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: enabled ? '#4caf50' : '#6b7280', flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)' }} noWrap>
{displayName}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)' }} noWrap>
{desc?.short || agent.agent_key}
</Typography>
</Box>
<Chip
label={schedule === 'on_demand' ? 'On-demand' : schedule}
size="small"
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.65)', lineHeight: 1.6, mb: 1.5 }}>
{desc?.long || 'This agent contributes to your automated marketing workflow.'}
</Typography>
{agent.responsibilities.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>
Responsibilities
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{agent.responsibilities.map((r, i) => (
<Chip key={i} label={r} size="small" sx={{ height: 20, fontSize: 9, bgcolor: 'rgba(255,255,255,0.04)', color: 'rgba(255,255,255,0.55)' }} />
))}
</Box>
</Box>
)}
{agent.tools.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>
Tools
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{agent.tools.map((t, i) => (
<Chip key={i} label={t} size="small" variant="outlined" sx={{ height: 20, fontSize: 9, borderColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.45)' }} />
))}
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
);
})}
</Box>
)}
{!loading && agents.length === 0 && (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', py: 3 }}>
Complete onboarding to configure your agent team.
</Typography>
)}
</DialogContent>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<DialogActions sx={{ px: 3, py: 1.5 }}>
<Button onClick={() => setOpen(false)} sx={{ textTransform: 'none', color: 'rgba(255,255,255,0.7)' }}>
Close
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default AgentHelpModal;

View File

@@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
Chip,
Collapse,
LinearProgress,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
WarningAmber as WarningAmberIcon,
Error as ErrorIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Schedule as ScheduleIcon,
} from '@mui/icons-material';
import { AgentEventItem, AgentRunItem, AgentAlertItem } from '../../hooks/useAgentHuddleFeed';
interface CommitteeProposal {
agent: string;
title: string;
pillar_id: string;
priority: string;
valid: boolean;
accepted: boolean;
reasoning?: string;
rejected_reason?: string | null;
estimated_time?: number;
action_type?: string;
}
interface CommitteePayload {
agents_polled: number;
total_proposals: number;
accepted_count: number;
rejected_count: number;
proposals: CommitteeProposal[];
}
// Agent type mapping: source_agent → agent_type for run cross-ref
const AGENT_TYPE_MAP: Record<string, string> = {
ContentStrategyAgent: 'content_strategist',
StrategyArchitectAgent: 'strategy_architect',
SEOOptimizationAgent: 'seo_specialist',
SocialAmplificationAgent: 'social_media_manager',
CompetitorResponseAgent: 'competitor_analyst',
ContentGapRadarAgent: 'content_gap_radar',
};
const AGENT_INFO: Record<string, { label: string; short: string; desc: string }> = {
ContentStrategyAgent: { label: 'Content Strategy', short: 'Strategy', desc: 'Content planning based on your pillars & topics' },
StrategyArchitectAgent: { label: 'Strategy Architect', short: 'Architect', desc: 'Semantic gap discovery from your content index' },
SEOOptimizationAgent: { label: 'SEO Optimization', short: 'SEO', desc: 'Technical SEO, keywords & performance' },
SocialAmplificationAgent: { label: 'Social Amplification', short: 'Social', desc: 'Social media distribution & engagement' },
CompetitorResponseAgent: { label: 'Competitor Response', short: 'Competitor', desc: 'Competitor content monitoring & response' },
ContentGapRadarAgent: { label: 'Content Gap Radar', short: 'Gap Radar', desc: 'ROI-ranked content gap opportunities' },
};
type AgentHealth = 'good' | 'warning' | 'error' | 'inactive';
interface AgentStatus {
sourceName: string; // class name (from proposals)
agentType: string; // agent_type value (from runs)
label: string;
short: string;
desc: string;
health: AgentHealth;
healthReason: string;
// From committee
totalProposals: number;
acceptedProposals: number;
proposals: CommitteeProposal[];
// From runs
latestRun: AgentRunItem | null;
// From alerts
alertCount: number;
}
const healthIcon = (h: AgentHealth) => {
if (h === 'good') return <CheckCircleIcon sx={{ fontSize: 20, color: '#4caf50' }} />;
if (h === 'warning') return <WarningAmberIcon sx={{ fontSize: 20, color: '#ff9800' }} />;
return <ErrorIcon sx={{ fontSize: 20, color: '#f44336' }} />;
};
const healthColor = (h: AgentHealth) => {
if (h === 'good') return '#4caf50';
if (h === 'warning') return '#ff9800';
return '#f44336';
};
// ─── Agent Card ────────────────────────────────────
const AgentCard: React.FC<{
status: AgentStatus;
expanded: boolean;
onToggle: () => void;
latestRuns: AgentRunItem[];
}> = ({ status, expanded, onToggle, latestRuns }) => {
const color = healthColor(status.health);
const pct = status.totalProposals > 0 ? (status.acceptedProposals / status.totalProposals) * 100 : 0;
return (
<Box>
<Box
onClick={onToggle}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1.25,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.04)',
border: `1px solid ${color}22`,
cursor: 'pointer',
transition: 'background 0.2s, border-color 0.2s',
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)', borderColor: `${color}44` },
}}
>
{healthIcon(status.health)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 700, fontSize: 13, lineHeight: 1.3 }}>
{status.label}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25 }}>
{status.totalProposals > 0 && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 10 }}>
{status.acceptedProposals}/{status.totalProposals} proposals
</Typography>
)}
{status.alertCount > 0 && (
<Chip
label={`${status.alertCount} alert${status.alertCount > 1 ? 's' : ''}`}
size="small"
sx={{ height: 16, fontSize: 9, fontWeight: 700, bgcolor: 'rgba(244,67,54,0.15)', color: '#f44336' }}
/>
)}
{status.latestRun && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', fontSize: 10, display: 'flex', alignItems: 'center', gap: 0.25 }}>
<ScheduleIcon sx={{ fontSize: 10 }} />
{timeAgo(status.latestRun.finished_at || status.latestRun.started_at)}
</Typography>
)}
</Box>
</Box>
{/* Mini bar for proposal acceptance */}
{status.totalProposals > 0 && (
<Box sx={{ width: 40 }}>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 4,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.08)',
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
</Box>
)}
{expanded ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
{/* Expanded details */}
<Collapse in={expanded}>
<Box sx={{ pl: 1.5, pr: 1.5, pb: 1, pt: 0.75 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, mb: 0.5, display: 'block' }}>
{status.desc}
</Typography>
{/* Proposals from this agent */}
{status.proposals.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
Proposals
</Typography>
{status.proposals.map((p, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.3, px: 1, borderRadius: 1, bgcolor: p.accepted ? 'rgba(76,175,80,0.06)' : 'transparent' }}>
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
{p.title}
</Typography>
<Chip label={p.pillar_id} size="small" sx={{
height: 18, fontSize: 9, fontWeight: 600,
bgcolor: p.valid ? 'rgba(102,126,234,0.15)' : 'rgba(244,67,54,0.15)',
color: p.valid ? '#8b9cf7' : '#f44336',
}} />
<Chip label={p.priority} size="small" sx={{
height: 18, fontSize: 9, fontWeight: 600, textTransform: 'capitalize',
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
}} />
<Chip label={p.accepted ? '✓' : '—'} size="small" sx={{
height: 18, fontSize: 9, fontWeight: 700,
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
color: p.accepted ? '#4caf50' : '#9e9e9e',
}} />
</Box>
))}
</Box>
)}
{/* Latest runs */}
{latestRuns.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
Recent Runs
</Typography>
{latestRuns.slice(0, 3).map((run) => (
<Box key={run.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.3, px: 1 }}>
<Box sx={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
bgcolor: run.status === 'completed' || run.success ? '#4caf50' : run.status === 'error' || run.success === false ? '#f44336' : '#ff9800',
}} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontSize: 10, flex: 1 }}>
{run.status || 'running'}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9 }}>
{timeAgo(run.finished_at || run.started_at)}
</Typography>
</Box>
))}
</Box>
)}
</Box>
</Collapse>
</Box>
);
};
// ─── Main Component ─────────────────────────────────
const AgentStatusPanel: React.FC<{
events: AgentEventItem[];
runs: AgentRunItem[];
alerts: AgentAlertItem[];
}> = ({ events, runs, alerts }) => {
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const agents = useMemo<AgentStatus[]>(() => {
// Parse committee meeting data
const meeting = events.find((e) => e.event_type === 'committee_meeting');
const payload = meeting?.payload
? (typeof meeting.payload === 'string' ? JSON.parse(meeting.payload) : meeting.payload) as CommitteePayload
: null;
// Group proposals by agent
const proposalMap = new Map<string, CommitteeProposal[]>();
if (payload) {
for (const p of payload.proposals) {
if (!proposalMap.has(p.agent)) proposalMap.set(p.agent, []);
proposalMap.get(p.agent)!.push(p);
}
}
// Build agent status list from known agents
const agentKeys = Object.keys(AGENT_INFO);
const result: AgentStatus[] = [];
for (const sourceName of agentKeys) {
const info = AGENT_INFO[sourceName];
const agentType = AGENT_TYPE_MAP[sourceName];
const proposals = proposalMap.get(sourceName) || [];
// Proposals stats
const totalProposals = proposals.length;
const acceptedProposals = proposals.filter((p) => p.accepted).length;
// Latest run
const agentRuns = runs.filter((r) => r.agent_type === agentType);
const latestRun = agentRuns.length > 0 ? agentRuns[0] : null;
// Alerts
const alertCount = alerts.filter((a) => a.title && a.title.includes(info.short)).length;
// Determine health
let health: AgentHealth = 'good';
let healthReason = 'All systems good';
if (latestRun?.status === 'error' || latestRun?.success === false) {
health = 'error';
healthReason = 'Latest run failed';
} else if (alertCount > 0) {
health = 'warning';
healthReason = `${alertCount} alert${alertCount > 1 ? 's' : ''}`;
} else if (totalProposals > 0 && acceptedProposals === 0) {
health = 'warning';
healthReason = 'All proposals rejected';
} else if (totalProposals > 0 && acceptedProposals < totalProposals) {
health = 'warning';
healthReason = `${totalProposals - acceptedProposals} proposal${totalProposals - acceptedProposals > 1 ? 's' : ''} not adopted`;
}
result.push({
sourceName,
agentType,
...info,
health,
healthReason,
totalProposals,
acceptedProposals,
proposals,
latestRun,
alertCount,
});
}
// Sort: error first, then warning, then good, then inactive
const healthRank = { error: 0, warning: 1, good: 2, inactive: 3 };
result.sort((a, b) => healthRank[a.health] - healthRank[b.health]);
return result;
}, [events, runs, alerts]);
return (
<Box
sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
backdropFilter: 'blur(22px)',
WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
p: 2.5,
mb: 2,
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Agent Status
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1 }}>
{agents.map((agent) => (
<AgentCard
key={agent.sourceName}
status={agent}
expanded={expandedAgent === agent.sourceName}
onToggle={() => setExpandedAgent(expandedAgent === agent.sourceName ? null : agent.sourceName)}
latestRuns={runs.filter((r) => r.agent_type === agent.agentType)}
/>
))}
</Box>
</Box>
);
};
function timeAgo(dateStr?: string | null): string {
if (!dateStr) return '—';
const ms = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(ms / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export default AgentStatusPanel;

View File

@@ -0,0 +1,167 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
Chip,
Button,
Collapse,
IconButton,
} from '@mui/material';
import {
WarningAmber as WarningIcon,
Error as ErrorIcon,
InfoOutlined as InfoIcon,
CheckCircle as CheckCircleIcon,
Close as CloseIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
import { AgentAlertItem, AgentApprovalItem } from '../../hooks/useAgentHuddleFeed';
interface AlertBannerProps {
alerts: AgentAlertItem[];
approvals: AgentApprovalItem[];
}
const severityIcon = (sev?: string) => {
if (sev === 'error' || sev === 'critical') return <ErrorIcon sx={{ fontSize: 18, color: '#f44336' }} />;
if (sev === 'warning') return <WarningIcon sx={{ fontSize: 18, color: '#ff9800' }} />;
return <InfoIcon sx={{ fontSize: 18, color: '#2196f3' }} />;
};
const severityBg = (sev?: string) => {
if (sev === 'error' || sev === 'critical') return 'rgba(244,67,54,0.1)';
if (sev === 'warning') return 'rgba(255,152,0,0.1)';
return 'rgba(33,150,243,0.1)';
};
const severityBorder = (sev?: string) => {
if (sev === 'error' || sev === 'critical') return 'rgba(244,67,54,0.25)';
if (sev === 'warning') return 'rgba(255,152,0,0.25)';
return 'rgba(33,150,243,0.25)';
};
const AlertBanner: React.FC<AlertBannerProps> = ({ alerts, approvals }) => {
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
const [dismissing, setDismissing] = useState<Set<number>>(new Set());
const [approvalsOpen, setApprovalsOpen] = useState(false);
const handleDismiss = async (alertId: number) => {
if (dismissing.has(alertId)) return;
setDismissing((s) => new Set(s).add(alertId));
try {
await apiClient.post(`/api/agents/alerts/${alertId}/mark-read`);
setDismissed((s) => new Set(s).add(alertId));
} catch {
setDismissed((s) => new Set(s).add(alertId));
} finally {
setDismissing((s) => { const next = new Set(s); next.delete(alertId); return next; });
}
};
const unreadAlerts = useMemo(
() => alerts.filter((a) => a.id && !dismissed.has(a.id)),
[alerts, dismissed],
);
const pendingApprovals = useMemo(
() => approvals.filter((a) => a.status === 'pending'),
[approvals],
);
if (unreadAlerts.length === 0 && pendingApprovals.length === 0) return null;
return (
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Alerts */}
{unreadAlerts.slice(0, 5).map((alert) => (
<Box
key={alert.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: severityBg(alert.severity),
border: `1px solid ${severityBorder(alert.severity)}`,
}}
>
{severityIcon(alert.severity)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 600, fontSize: 13 }}>
{alert.title || 'Alert'}
</Typography>
{alert.message && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
{alert.message}
</Typography>
)}
</Box>
<IconButton
size="small"
disabled={dismissing.has(alert.id!)}
onClick={() => alert.id && handleDismiss(alert.id)}
sx={{ color: 'rgba(255,255,255,0.3)', '&:hover': { color: 'rgba(255,255,255,0.6)' }, '&.Mui-disabled': { opacity: 0.3 } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
{/* Pending approvals */}
{pendingApprovals.length > 0 && (
<Box
sx={{
borderRadius: 2,
bgcolor: 'rgba(102,126,234,0.1)',
border: '1px solid rgba(102,126,234,0.2)',
overflow: 'hidden',
}}
>
<Box
onClick={() => setApprovalsOpen(!approvalsOpen)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1,
cursor: 'pointer',
'&:hover': { bgcolor: 'rgba(255,255,255,0.03)' },
}}
>
<CheckCircleIcon sx={{ fontSize: 18, color: '#8b9cf7' }} />
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', fontWeight: 600, flex: 1, fontSize: 13 }}>
{pendingApprovals.length} approval{pendingApprovals.length > 1 ? 's' : ''} pending
</Typography>
<Chip
label={pendingApprovals.length}
size="small"
sx={{ height: 20, fontSize: 10, fontWeight: 700, bgcolor: 'rgba(102,126,234,0.2)', color: '#8b9cf7' }}
/>
{approvalsOpen ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} />}
</Box>
<Collapse in={approvalsOpen}>
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}>
{pendingApprovals.map((app) => (
<Box key={app.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', flex: 1 }}>
{app.action_type || 'Action'} · {app.status}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10 }}>
{app.created_at ? new Date(app.created_at).toLocaleTimeString() : ''}
</Typography>
</Box>
))}
</Box>
</Collapse>
</Box>
)}
</Box>
);
};
export default AlertBanner;

View File

@@ -0,0 +1,413 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Collapse,
TextField,
InputAdornment,
Button,
Tooltip,
} from '@mui/material';
import {
Search as SearchIcon,
FileDownload as FileDownloadIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface CommitteeProposal {
agent: string;
title: string;
pillar_id: string;
priority: string;
valid: boolean;
accepted: boolean;
reasoning?: string;
rejected_reason?: string | null;
estimated_time?: number;
action_type?: string;
}
interface CommitteePayload {
agents_polled: number;
total_proposals: number;
accepted_count: number;
rejected_count: number;
proposals: CommitteeProposal[];
}
const PILLAR_LABELS: Record<string, string> = {
plan: 'Plan',
generate: 'Generate',
publish: 'Publish',
analyze: 'Analyze',
engage: 'Engage',
remarket: 'Remarket',
};
type SortKey = 'agent' | 'title' | 'pillar_id' | 'priority' | 'valid' | 'accepted';
type SortDir = 'asc' | 'desc';
type FilterStatus = 'all' | 'accepted' | 'rejected' | 'invalid';
const sortProposals = (props: CommitteeProposal[], key: SortKey, dir: SortDir): CommitteeProposal[] => {
return [...props].sort((a, b) => {
const aVal = String(a[key] ?? '');
const bVal = String(b[key] ?? '');
const cmp = aVal.localeCompare(bVal);
return dir === 'asc' ? cmp : -cmp;
});
};
const CommitteeAuditTable: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
const [sortKey, setSortKey] = useState<SortKey>('agent');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all');
const [filterAgent, setFilterAgent] = useState<string | null>(null);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const meeting = useMemo<CommitteePayload | null>(() => {
const last = events.find((e) => e.event_type === 'committee_meeting');
if (!last?.payload) return null;
return (typeof last.payload === 'string' ? JSON.parse(last.payload) : last.payload) as CommitteePayload;
}, [events]);
const allAgents = useMemo<string[]>(() => {
if (!meeting) return [];
return Array.from(new Set(meeting.proposals.map((p) => p.agent)));
}, [meeting]);
const filtered = useMemo<CommitteeProposal[]>(() => {
if (!meeting) return [];
let list = meeting.proposals;
if (filterStatus === 'accepted') list = list.filter((p) => p.accepted);
else if (filterStatus === 'rejected') list = list.filter((p) => !p.accepted);
else if (filterStatus === 'invalid') list = list.filter((p) => !p.valid);
if (filterAgent) list = list.filter((p) => p.agent === filterAgent);
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter((p) => p.title.toLowerCase().includes(q) || p.agent.toLowerCase().includes(q));
}
return sortProposals(list, sortKey, sortDir);
}, [meeting, filterStatus, filterAgent, search, sortKey, sortDir]);
const handleSort = (key: SortKey) => () => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
const exportCsv = () => {
if (!meeting) return;
const headers = ['Agent', 'Title', 'Pillar', 'Priority', 'Valid', 'Accepted', 'Rejected Reason', 'Reasoning', 'Est. Time', 'Action Type'];
const rows = meeting.proposals.map((p) => [
p.agent,
`"${p.title.replace(/"/g, '""')}"`,
p.pillar_id,
p.priority,
p.valid ? 'Yes' : 'No',
p.accepted ? 'Yes' : 'No',
p.rejected_reason ? `"${p.rejected_reason.replace(/"/g, '""')}"` : '',
p.reasoning ? `"${p.reasoning.replace(/"/g, '""')}"` : '',
p.estimated_time ?? '',
p.action_type ?? '',
].join(','));
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `committee_audit_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (!meeting) return null;
return (
<Box
sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
backdropFilter: 'blur(22px)',
WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
p: 2.5,
mb: 2,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
Committee Audit {meeting.total_proposals} proposals
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<FileDownloadIcon />}
onClick={exportCsv}
sx={{
color: 'rgba(255,255,255,0.7)',
borderColor: 'rgba(255,255,255,0.2)',
fontSize: 12,
fontWeight: 600,
textTransform: 'none',
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
CSV
</Button>
</Box>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
placeholder="Search proposals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />
</InputAdornment>
),
}}
sx={{
minWidth: 200,
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.8)',
fontSize: 13,
'& fieldset': { borderColor: 'rgba(255,255,255,0.12)' },
'&:hover fieldset': { borderColor: 'rgba(255,255,255,0.25)' },
'&.Mui-focused fieldset': { borderColor: 'rgba(102,126,234,0.5)' },
},
}}
/>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{(['all', 'accepted', 'rejected', 'invalid'] as FilterStatus[]).map((s) => (
<Chip
key={s}
label={s.charAt(0).toUpperCase() + s.slice(1)}
size="small"
onClick={() => setFilterStatus(s)}
sx={{
height: 26,
fontSize: 11,
fontWeight: 600,
textTransform: 'capitalize',
bgcolor: filterStatus === s ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
color: filterStatus === s ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
border: `1px solid ${filterStatus === s ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
'&:hover': { bgcolor: filterStatus === s ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
}}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{allAgents.map((a) => (
<Chip
key={a}
label={a}
size="small"
onClick={() => setFilterAgent(filterAgent === a ? null : a)}
sx={{
height: 26,
fontSize: 11,
fontWeight: 600,
bgcolor: filterAgent === a ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
color: filterAgent === a ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
border: `1px solid ${filterAgent === a ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
'&:hover': { bgcolor: filterAgent === a ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
}}
/>
))}
</Box>
</Box>
{/* Table */}
<TableContainer sx={{ maxHeight: 420, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.15)', borderRadius: 3 } }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
{([{ key: 'agent', label: 'Agent' }, { key: 'title', label: 'Title' }, { key: 'pillar_id', label: 'Pillar' }, { key: 'priority', label: 'Priority' }, { key: 'valid', label: 'Valid' }, { key: 'accepted', label: 'Accepted' }] as { key: SortKey; label: string }[]).map(({ key, label }) => (
<TableCell
key={key}
sx={{
color: 'rgba(255,255,255,0.5)',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
borderBottom: '1px solid rgba(255,255,255,0.08)',
bgcolor: 'rgba(0,0,0,0.3)',
py: 1,
}}
>
<TableSortLabel
active={sortKey === key}
direction={sortKey === key ? sortDir : 'asc'}
onClick={handleSort(key)}
sx={{ color: 'inherit !important', '& .MuiTableSortLabel-icon': { color: 'rgba(255,255,255,0.5) !important' } }}
>
{label}
</TableSortLabel>
</TableCell>
))}
<TableCell sx={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid rgba(255,255,255,0.08)', bgcolor: 'rgba(0,0,0,0.3)', py: 1 }}>
Reason
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map((p, i) => {
const isExpanded = expandedRow === i;
return (
<React.Fragment key={i}>
<TableRow
hover
onClick={() => setExpandedRow(isExpanded ? null : i)}
sx={{
cursor: 'pointer',
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
'& td': { borderBottom: '1px solid rgba(255,255,255,0.04)' },
opacity: p.accepted ? 1 : 0.55,
}}
>
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontWeight: 600, py: 0.75 }}>
{p.agent}
</TableCell>
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, py: 0.75 }}>
{p.title}
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Chip
label={PILLAR_LABELS[p.pillar_id] || p.pillar_id}
size="small"
sx={{
height: 20,
fontSize: 10,
fontWeight: 600,
bgcolor: p.valid ? 'rgba(102,126,234,0.2)' : 'rgba(244,67,54,0.2)',
color: p.valid ? '#8b9cf7' : '#f44336',
}}
/>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Chip
label={p.priority}
size="small"
sx={{
height: 20,
fontSize: 10,
fontWeight: 600,
textTransform: 'capitalize',
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
}}
/>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Chip
label={p.valid ? 'Yes' : 'No'}
size="small"
sx={{
height: 20,
fontSize: 10,
fontWeight: 600,
bgcolor: p.valid ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
color: p.valid ? '#4caf50' : '#f44336',
}}
/>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Chip
label={p.accepted ? 'Yes' : 'No'}
size="small"
sx={{
height: 20,
fontSize: 10,
fontWeight: 600,
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
color: p.accepted ? '#4caf50' : '#9e9e9e',
}}
/>
</TableCell>
<TableCell sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 11, py: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{p.rejected_reason || (p.accepted ? '—' : 'Duplicate / lower priority')}
{isExpanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={7} sx={{ py: 0, borderBottom: 'none', bgcolor: 'rgba(0,0,0,0.2)' }}>
<Collapse in={isExpanded}>
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 700, display: 'block', mb: 0.5, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Reasoning
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 1.6, mb: 1.5 }}>
{p.reasoning || 'No reasoning provided.'}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
Est. Time
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
{p.estimated_time ? `${p.estimated_time} min` : '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
Action Type
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
{p.action_type || '—'}
</Typography>
</Box>
</Box>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} sx={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)', fontSize: 13, py: 4 }}>
No proposals match current filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default CommitteeAuditTable;

View File

@@ -0,0 +1,493 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
Chip,
LinearProgress,
Collapse,
Button,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
WarningAmber as WarningAmberIcon,
Error as ErrorIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
ArrowForward as ArrowForwardIcon,
InfoOutlined as InfoIcon,
} from '@mui/icons-material';
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface CommitteeProposal {
agent: string;
title: string;
pillar_id: string;
priority: string;
valid: boolean;
accepted: boolean;
reasoning?: string;
rejected_reason?: string | null;
estimated_time?: number;
action_type?: string;
}
interface CommitteePayload {
agents_polled: number;
total_proposals: number;
accepted_count: number;
rejected_count: number;
proposals: CommitteeProposal[];
}
const PILLAR_ORDER = ['plan', 'generate', 'publish', 'analyze', 'engage', 'remarket'];
const PILLAR_INFO: Record<string, { label: string; short: string; desc: string }> = {
plan: { label: 'Plan', short: 'Plan', desc: 'Strategy & planning' },
generate: { label: 'Generate', short: 'Create', desc: 'Content creation' },
publish: { label: 'Publish', short: 'Pub.', desc: 'Publishing & scheduling' },
analyze: { label: 'Analyze', short: 'Audit', desc: 'Performance review' },
engage: { label: 'Engage', short: 'Share', desc: 'Social engagement' },
remarket: { label: 'Remarket', short: 'ReMkt', desc: 'Repurpose & promote' },
};
// ─── Status Banner ──────────────────────────────────
const statusMeta = (accepted: number, total: number) => {
const pct = total > 0 ? accepted / total : 0;
if (pct >= 0.8) return { color: '#4caf50', bg: 'rgba(76,175,80,0.12)', icon: <CheckCircleIcon sx={{ fontSize: 20, color: '#4caf50' }} />, text: 'All systems good' };
if (pct >= 0.5) return { color: '#ff9800', bg: 'rgba(255,152,0,0.12)', icon: <WarningAmberIcon sx={{ fontSize: 20, color: '#ff9800' }} />, text: 'Needs review' };
return { color: '#f44336', bg: 'rgba(244,67,54,0.12)', icon: <ErrorIcon sx={{ fontSize: 20, color: '#f44336' }} />, text: 'Attention needed' };
};
const StatusBanner: React.FC<{ accepted: number; total: number; agents: number }> = ({ accepted, total, agents }) => {
const meta = statusMeta(accepted, total);
const pct = total > 0 ? Math.round(accepted / total * 100) : 0;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: meta.bg,
mb: 1.5,
}}
>
{meta.icon}
<Typography variant="body2" sx={{ color: meta.color, fontWeight: 600, flex: 1 }}>
{meta.text} <Box component="span" sx={{ fontWeight: 400 }}>{accepted} of {total} proposals adopted from {agents} areas</Box>
</Typography>
<Typography variant="h6" sx={{ color: meta.color, fontWeight: 800, fontSize: '1.1rem' }}>
{pct}%
</Typography>
</Box>
);
};
// ─── Adoption Bar ───────────────────────────────────
const AdoptionBar: React.FC<{ accepted: number; total: number }> = ({ accepted, total }) => {
const pct = total > 0 ? accepted / total * 100 : 0;
const color = pct >= 80 ? '#4caf50' : pct >= 50 ? '#ff9800' : '#f44336';
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Adoption
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>
Adopted <Box component="span" sx={{ color }}>{accepted}</Box> of {total} proposals
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 8,
borderRadius: 4,
bgcolor: 'rgba(255,255,255,0.08)',
'& .MuiLinearProgress-bar': {
background: `linear-gradient(90deg, rgba(102,126,234,0.8), ${color})`,
borderRadius: 4,
},
}}
/>
</Box>
);
};
// ─── Coverage Flow ──────────────────────────────────
const coverageHealth = (count: number): { color: string; label: string; dot: string } => {
if (count >= 3) return { color: '#4caf50', label: 'covered', dot: '●' };
if (count >= 1) return { color: '#ff9800', label: 'light', dot: '◕' };
return { color: '#f44336', label: 'missing', dot: '○' };
};
const CoverageFlow: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
const counts = PILLAR_ORDER.map((p) => ({
...PILLAR_INFO[p],
key: p,
count: proposals.filter((pr) => pr.pillar_id === p).length,
}));
return (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Today's Coverage
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'nowrap', overflow: 'auto', pb: 0.5 }}>
{counts.map((p, i) => {
const health = coverageHealth(p.count);
return (
<React.Fragment key={p.key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
minWidth: 56,
py: 1,
px: 0.75,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.04)',
border: `1px solid ${health.color}22`,
}}
>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 700, fontSize: 11, lineHeight: 1.2 }}>
{p.short}
</Typography>
<Typography variant="h6" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 800, fontSize: '1rem', lineHeight: 1.2 }}>
{p.count}
</Typography>
<Typography variant="caption" sx={{ color: health.color, fontSize: 9, fontWeight: 600 }}>
{health.label}
</Typography>
</Box>
{i < counts.length - 1 && (
<ArrowForwardIcon sx={{ mx: 0.25, color: 'rgba(255,255,255,0.15)', fontSize: 16, flexShrink: 0 }} />
)}
</React.Fragment>
);
})}
</Box>
</Box>
);
};
// ─── Rejected List (redesigned) ─────────────────────
const plainReason = (p: CommitteeProposal): string => {
if (p.rejected_reason) return p.rejected_reason;
if (!p.valid) return `"${p.pillar_id}" isn't a valid workflow phase this is a system configuration issue.`;
return 'This suggestion was similar to an existing task or had lower priority.';
};
const actionForProposal = (p: CommitteeProposal): { label: string; icon?: React.ReactNode } | null => {
const title = p.title.toLowerCase();
if (title.includes('twitter') || title.includes('tweet')) {
return { label: 'Connect Twitter' };
}
if (title.includes('linkedin')) {
return { label: 'Connect LinkedIn' };
}
if (title.includes('facebook') || title.includes('instagram')) {
return { label: 'Connect Social' };
}
return null;
};
const RejectedList: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
const rejected = proposals.filter((p) => !p.accepted);
const [open, setOpen] = useState(false);
if (rejected.length === 0) return null;
return (
<Box sx={{ pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Box
onClick={() => setOpen(!open)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
py: 0.5,
borderRadius: 1,
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
}}
>
<Chip
label={rejected.length}
size="small"
sx={{
height: 20,
minWidth: 20,
fontSize: 11,
fontWeight: 700,
bgcolor: 'rgba(244,67,54,0.2)',
color: '#f44336',
}}
/>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>
suggestion{rejected.length > 1 ? 's' : ''} not included
</Typography>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', mr: 0.5 }}>
{open ? 'hide' : 'view'}
</Typography>
{open ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
</Box>
<Collapse in={open}>
<Box sx={{ pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{rejected.map((p, i) => {
const action = actionForProposal(p);
return (
<Box
key={i}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<InfoIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.25)', mt: 0.25, flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, mb: 0.25 }}>
&ldquo;{p.title}&rdquo;
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', lineHeight: 1.4 }}>
{plainReason(p)}
</Typography>
{action && (
<Button
size="small"
variant="outlined"
sx={{
mt: 0.75,
height: 24,
fontSize: 11,
fontWeight: 600,
textTransform: 'none',
color: '#8b9cf7',
borderColor: 'rgba(102,126,234,0.3)',
'&:hover': { borderColor: 'rgba(102,126,234,0.6)', bgcolor: 'rgba(102,126,234,0.1)' },
}}
>
{action.label}
</Button>
)}
</Box>
<Chip
label={p.agent}
size="small"
sx={{
height: 18,
fontSize: 9,
fontWeight: 600,
bgcolor: 'rgba(255,255,255,0.06)',
color: 'rgba(255,255,255,0.4)',
flexShrink: 0,
}}
/>
</Box>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
};
// ─── Agent Row (details section) ────────────────────
type AgentStatus = 'all_accepted' | 'partial' | 'all_rejected';
interface AgentSummary {
name: string;
total: number;
accepted: number;
status: AgentStatus;
proposals: CommitteeProposal[];
}
const agentStatusIcon = (s: AgentStatus) => {
if (s === 'all_accepted') return <CheckCircleIcon sx={{ fontSize: 18, color: '#4caf50' }} />;
if (s === 'partial') return <WarningAmberIcon sx={{ fontSize: 18, color: '#ff9800' }} />;
return <ErrorIcon sx={{ fontSize: 18, color: '#f44336' }} />;
};
const agentStatusColor = (s: AgentStatus): 'success' | 'warning' | 'error' => {
if (s === 'all_accepted') return 'success';
if (s === 'partial') return 'warning';
return 'error';
};
const AgentRow: React.FC<{ agent: AgentSummary; expanded: boolean; onToggle: () => void }> = ({ agent, expanded, onToggle }) => {
const pct = agent.total > 0 ? agent.accepted / agent.total : 0;
return (
<Box>
<Box
onClick={onToggle}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.6, px: 1.5, borderRadius: 2,
cursor: 'pointer', transition: 'background 0.2s',
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
}}
>
{agentStatusIcon(agent.status)}
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 140, color: 'rgba(255,255,255,0.9)' }}>
{agent.name}
</Typography>
<Box sx={{ flex: 1, maxWidth: 140 }}>
<LinearProgress
variant="determinate"
value={pct * 100}
color={agentStatusColor(agent.status)}
sx={{ height: 5, borderRadius: 2.5, bgcolor: 'rgba(255,255,255,0.08)' }}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', minWidth: 80, textAlign: 'right' }}>
{agent.accepted}/{agent.total}
</Typography>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} />}
</Box>
<Collapse in={expanded}>
<Box sx={{ ml: 5, mr: 1.5, mb: 0.5 }}>
{agent.proposals.map((p, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35, px: 1, borderRadius: 1, bgcolor: p.accepted ? 'rgba(76,175,80,0.06)' : 'transparent' }}>
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.8)' }}>
{p.title}
</Typography>
<Chip label={PILLAR_INFO[p.pillar_id]?.label || p.pillar_id} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600,
bgcolor: p.valid ? 'rgba(102,126,234,0.2)' : 'rgba(244,67,54,0.2)',
color: p.valid ? '#8b9cf7' : '#f44336',
border: `1px solid ${p.valid ? 'rgba(102,126,234,0.3)' : 'rgba(244,67,54,0.3)'}`,
}} />
<Chip label={p.priority} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600, textTransform: 'capitalize',
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
}} />
<Chip label={p.accepted ? 'Accepted' : 'Skipped'} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600,
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
color: p.accepted ? '#4caf50' : '#9e9e9e',
}} />
</Box>
))}
</Box>
</Collapse>
</Box>
);
};
// ─── Main Component ─────────────────────────────────
const CommitteeSummary: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
const [showDetails, setShowDetails] = useState(false);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const meeting = useMemo<CommitteePayload | null>(() => {
const last = events.find((e) => e.event_type === 'committee_meeting');
if (!last?.payload) return null;
return (typeof last.payload === 'string' ? JSON.parse(last.payload) : last.payload) as CommitteePayload;
}, [events]);
const agents = useMemo<AgentSummary[]>(() => {
if (!meeting) return [];
const map = new Map<string, CommitteeProposal[]>();
for (const p of meeting.proposals) {
if (!map.has(p.agent)) map.set(p.agent, []);
map.get(p.agent)!.push(p);
}
return Array.from(map.entries()).map(([name, proposals]) => {
const accepted = proposals.filter((p) => p.accepted).length;
const total = proposals.length;
let status: AgentStatus = 'all_accepted';
if (accepted === 0) status = 'all_rejected';
else if (accepted < total) status = 'partial';
return { name, total, accepted, status, proposals };
});
}, [meeting]);
if (!meeting) return null;
const summaryLine = `ALwrity reviewed ${meeting.total_proposals} suggestions across ${meeting.agents_polled} areas of your content workflow and built today's plan from ${meeting.accepted_count} of them.`;
return (
<Box
sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.06) 100%)',
backdropFilter: 'blur(22px)',
WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.16)',
borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.25)',
p: 2.5,
mb: 2,
}}
>
{/* Header + summary line */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem', mb: 0.25 }}>
Daily Committee Brief
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block', maxWidth: 480 }}>
{summaryLine}
</Typography>
</Box>
<Button
size="small"
variant="text"
onClick={() => setShowDetails(!showDetails)}
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{
fontSize: 11,
fontWeight: 600,
textTransform: 'none',
color: 'rgba(255,255,255,0.5)',
flexShrink: 0,
'&:hover': { color: 'rgba(255,255,255,0.8)', bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
{showDetails ? 'Hide details' : 'Show details'}
</Button>
</Box>
{/* Status banner */}
<StatusBanner accepted={meeting.accepted_count} total={meeting.total_proposals} agents={meeting.agents_polled} />
{/* Adoption bar */}
<AdoptionBar accepted={meeting.accepted_count} total={meeting.total_proposals} />
{/* Coverage flow */}
<CoverageFlow proposals={meeting.proposals} />
{/* Rejected proposals */}
<RejectedList proposals={meeting.proposals} />
{/* Details section: agent-level breakdown */}
<Collapse in={showDetails}>
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 0.5, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Agent Breakdown
</Typography>
{agents.map((agent) => (
<AgentRow
key={agent.name}
agent={agent}
expanded={expandedAgent === agent.name}
onToggle={() => setExpandedAgent(expandedAgent === agent.name ? null : agent.name)}
/>
))}
</Box>
</Collapse>
</Box>
);
};
export default CommitteeSummary;

View File

@@ -0,0 +1,400 @@
import React, { useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Chip,
Collapse,
Button,
LinearProgress,
} from '@mui/material';
import {
WarningAmber as WarningIcon,
Error as ErrorIcon,
InfoOutlined as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Gavel as GavelIcon,
ArrowForward as ArrowForwardIcon,
OpenInNew as OpenInNewIcon,
} from '@mui/icons-material';
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface ReasoningIssue { title: string; reasoning: string; score: number }
interface PriorityIssue { title: string; pillar: string; priority: string; note: string }
interface PillarIssue { title: string; proposed_pillar: string; expected_pillar: string; note: string }
interface RejectedDetail { title: string; reason: string }
interface AgentIssue {
type: string; severity: string; count: number; summary: string;
details?: ReasoningIssue[] | PriorityIssue[] | PillarIssue[] | RejectedDetail[];
action_label?: string; action_url?: string | null;
}
interface AgentCritique {
agent: string; label: string; short: string;
score: number; health: string;
total_proposals: number; accepted: number; rejected: number;
acceptance_rate: number;
issues: AgentIssue[];
summary: string;
}
interface CoverageGap { pillar_id: string; severity: string; summary: string; action_label?: string; action_url?: string | null }
interface Overlap { title: string; pillar: string; agents: string[]; count: number; severity: string; summary: string; action_label?: string; action_url?: string | null }
interface AuditAlert { type: string; severity: string; agent?: string; label?: string; title: string; message: string; cta_path?: string | null }
interface AuditReport {
health_score: number; verdict: string;
agent_critiques: AgentCritique[];
coverage_gaps: CoverageGap[];
overstuffed_pillars?: CoverageGap[];
overlaps: Overlap[];
alerts: AuditAlert[];
audit_timestamp: string;
}
const healthColor = (score: number) => score >= 80 ? '#4caf50' : score >= 50 ? '#ff9800' : '#f44336';
const healthLabel = (score: number) => score >= 80 ? 'Healthy' : score >= 50 ? 'Needs review' : 'Failing';
// ── Health Ring ─────────────────────────────────────
const HealthRing: React.FC<{ score: number }> = ({ score }) => {
const color = healthColor(score);
const r = 36, circ = 2 * Math.PI * r;
const offset = circ - (score / 100) * circ;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<Box sx={{ position: 'relative', width: 80, height: 80, flexShrink: 0 }}>
<svg width={80} height={80} viewBox="0 0 80 80">
<circle cx={40} cy={40} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={6} />
<circle cx={40} cy={40} r={r} fill="none" stroke={color} strokeWidth={6}
strokeDasharray={circ} strokeDashoffset={offset}
transform="rotate(-90 40 40)" strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
/>
</svg>
<Typography sx={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: '1.3rem', color: color, lineHeight: 1,
}}>
{score}
</Typography>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)', fontSize: '0.9rem' }}>
Committee Health {healthLabel(score)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', lineHeight: 1.4, mt: 0.25 }}>
{score >= 80 ? 'All agents submitting quality proposals.' : score >= 50 ? 'Some agents need attention.' : 'Significant issues detected.'}
</Typography>
</Box>
</Box>
);
};
// ── Agent Critique Card ─────────────────────────────
const issueIcon = (sev: string) => {
if (sev === 'error') return <ErrorIcon sx={{ fontSize: 14, color: '#f44336' }} />;
if (sev === 'warning') return <WarningIcon sx={{ fontSize: 14, color: '#ff9800' }} />;
return <InfoIcon sx={{ fontSize: 14, color: '#2196f3' }} />;
};
const issueBg = (sev: string) => sev === 'error' ? 'rgba(244,67,54,0.08)' : sev === 'warning' ? 'rgba(255,152,0,0.08)' : 'rgba(33,150,243,0.08)';
const AgentCritiqueCard: React.FC<{ critique: AgentCritique; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ critique, onNavigate }) => {
const [expanded, setExpanded] = useState(false);
const color = healthColor(critique.score);
const hasIssues = critique.issues.length > 0;
return (
<Box sx={{
p: 1.5, borderRadius: 2,
bgcolor: critique.health === 'failing' ? 'rgba(244,67,54,0.04)' : critique.health === 'warning' ? 'rgba(255,152,0,0.04)' : 'rgba(76,175,80,0.04)',
border: `1px solid ${color}22`,
}}>
{/* Header row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)', fontSize: 13, flex: 1 }}>
{critique.label}
</Typography>
<Chip label={`${critique.accepted}/${critique.total_proposals}`} size="small" sx={{
height: 18, fontSize: 9, fontWeight: 700,
bgcolor: critique.acceptance_rate > 0.5 ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
color: critique.acceptance_rate > 0.5 ? '#4caf50' : '#f44336',
}} />
<Chip label={`${critique.score}/100`} size="small" sx={{
height: 18, fontSize: 9, fontWeight: 700, bgcolor: `${color}22`, color,
}} />
</Box>
{/* Mini bar */}
<LinearProgress variant="determinate" value={critique.score} sx={{
height: 3, borderRadius: 1.5, mb: 0.75,
bgcolor: 'rgba(255,255,255,0.06)',
'& .MuiLinearProgress-bar': { bgcolor: color },
}} />
{/* Summary */}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: 10, display: 'block' }}>
{critique.summary}
</Typography>
{/* Issues */}
{hasIssues && (
<>
<Box onClick={() => setExpanded(!expanded)} sx={{
display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.75, cursor: 'pointer',
'&:hover': { opacity: 0.8 }, userSelect: 'none',
}}>
<GavelIcon sx={{ fontSize: 12, color: 'rgba(255,255,255,0.3)' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10 }}>
{critique.issues.length} issue{critique.issues.length > 1 ? 's' : ''}
</Typography>
{expanded ? <ExpandLessIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
<Collapse in={expanded}>
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{critique.issues.map((issue, i) => (
<Box key={i} sx={{ p: 0.75, borderRadius: 1.5, bgcolor: issueBg(issue.severity), border: `1px solid ${issue.severity === 'error' ? 'rgba(244,67,54,0.15)' : issue.severity === 'warning' ? 'rgba(255,152,0,0.15)' : 'rgba(33,150,243,0.15)'}` }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
{issueIcon(issue.severity)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 600, fontSize: 10, display: 'block' }}>
{issue.summary}
</Typography>
{issue.details && (issue.details as any[]).slice(0, 2).map((d: any, j) => (
<Box key={j} sx={{ mt: 0.25 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 9, display: 'block' }}>
{d.title}: {d.reasoning || d.reason || d.note || ''}
</Typography>
</Box>
))}
{issue.details && (issue.details as any[]).length > 2 && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', fontSize: 9 }}>
+{(issue.details as any[]).length - 2} more
</Typography>
)}
</Box>
{issue.action_url && (
<Button
size="small"
variant="text"
onClick={(e) => { e.stopPropagation(); onNavigate(issue.action_url!); }}
sx={{ textTransform: 'none', fontSize: 9, color: '#4f46e5', py: 0, minWidth: 0, pl: 0.5 }}
endIcon={<ArrowForwardIcon sx={{ fontSize: 10 }} />}
>
{issue.action_label || 'View'}
</Button>
)}
</Box>
</Box>
))}
</Box>
</Collapse>
</>
)}
</Box>
);
};
// ── Coverage Gap Row ─────────────────────────────────
const GapRow: React.FC<{ gap: CoverageGap; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ gap, onNavigate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: 'rgba(255,152,0,0.06)' }}>
<WarningIcon sx={{ fontSize: 12, color: '#ff9800' }} />
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>{gap.summary}</Typography>
<Chip label={`pillar: ${gap.pillar_id}`} size="small" sx={{ height: 16, fontSize: 8, fontWeight: 600, bgcolor: 'rgba(255,152,0,0.1)', color: '#ff9800' }} />
<Button
size="small"
variant="outlined"
onClick={() => {
const url = gap.action_url || '/content-planning';
onNavigate(url, { pillarId: gap.pillar_id, source: 'quality_audit_gap' });
}}
sx={{
textTransform: 'none', fontSize: 9, py: 0.125, px: 0.75, minWidth: 0,
borderColor: 'rgba(255,152,0,0.3)', color: '#ff9800',
'&:hover': { borderColor: '#ff9800', bgcolor: 'rgba(255,152,0,0.08)' },
}}
>
{gap.action_label || 'Fill gap'}
</Button>
</Box>
);
// ── Overlap Row ──────────────────────────────────────
const OverlapRow: React.FC<{ overlap: Overlap; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ overlap, onNavigate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: 'rgba(33,150,243,0.06)' }}>
<InfoIcon sx={{ fontSize: 12, color: '#2196f3' }} />
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>{overlap.summary}</Typography>
<Button
size="small"
variant="outlined"
onClick={() => {
const url = overlap.action_url || '/content-planning';
onNavigate(url, { pillar: overlap.pillar, overlapAgents: overlap.agents, source: 'quality_audit_overlap' });
}}
sx={{
textTransform: 'none', fontSize: 9, py: 0.125, px: 0.75, minWidth: 0,
borderColor: 'rgba(33,150,243,0.3)', color: '#2196f3',
'&:hover': { borderColor: '#2196f3', bgcolor: 'rgba(33,150,243,0.08)' },
}}
>
{overlap.action_label || 'Resolve'}
</Button>
</Box>
);
// ── Alert Row ────────────────────────────────────────
const AlertRow: React.FC<{ alert: AuditAlert; onNavigate: (url: string) => void }> = ({ alert, onNavigate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: alert.severity === 'error' ? 'rgba(244,67,54,0.06)' : alert.severity === 'warning' ? 'rgba(255,152,0,0.06)' : 'rgba(33,150,243,0.06)' }}>
{alert.severity === 'error' ? <ErrorIcon sx={{ fontSize: 12, color: '#f44336' }} /> : alert.severity === 'warning' ? <WarningIcon sx={{ fontSize: 12, color: '#ff9800' }} /> : <InfoIcon sx={{ fontSize: 12, color: '#2196f3' }} />}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 600, fontSize: 10, display: 'block' }}>
{alert.title}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', fontSize: 9, display: 'block' }}>
{alert.message}
</Typography>
</Box>
{alert.cta_path && (
<Button
size="small"
variant="text"
onClick={() => onNavigate(alert.cta_path!)}
endIcon={<OpenInNewIcon sx={{ fontSize: 10 }} />}
sx={{ textTransform: 'none', fontSize: 9, color: '#4f46e5', py: 0, minWidth: 0 }}
>
View
</Button>
)}
</Box>
);
// ── Main Component ───────────────────────────────────
const QualityAuditPanel: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
const navigate = useNavigate();
const report = useMemo<AuditReport | null>(() => {
const evt = events.find((e) => e.event_type === 'quality_audit');
if (!evt?.payload) return null;
return (typeof evt.payload === 'string' ? JSON.parse(evt.payload) : evt.payload) as AuditReport;
}, [events]);
const [critiquesOpen, setCritiquesOpen] = useState(false);
const [gapsOpen, setGapsOpen] = useState(false);
const [overlapsOpen, setOverlapsOpen] = useState(false);
const handleNavigate = useCallback((url: string, state?: Record<string, unknown>) => {
if (url.startsWith('/')) {
navigate(url, { state: state || {} });
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}, [navigate]);
if (!report) return null;
const hasAlerts = report.alerts.length > 0;
const hasGaps = report.coverage_gaps.length > 0;
const hasOverlaps = report.overlaps.length > 0;
return (
<Box sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
backdropFilter: 'blur(22px)', WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.12)', borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25)', p: 2.5, mb: 2,
}}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<GavelIcon sx={{ fontSize: 18, color: healthColor(report.health_score) }} />
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
Committee Watchdog
</Typography>
{hasAlerts && (
<Chip label={`${report.alerts.length} alert${report.alerts.length > 1 ? 's' : ''}`} size="small" sx={{
ml: 'auto', height: 20, fontSize: 10, fontWeight: 700,
bgcolor: 'rgba(244,67,54,0.15)', color: '#f44336',
}} />
)}
</Box>
{/* Health gauge + verdict */}
<HealthRing score={report.health_score} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', mb: 1.5, lineHeight: 1.4, fontStyle: 'italic' }}>
{report.verdict}
</Typography>
{/* Alerts */}
{hasAlerts && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(244,67,54,0.8)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, display: 'block', mb: 0.5 }}>
Alerts ({report.alerts.length})
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{report.alerts.map((a, i) => <AlertRow key={i} alert={a} onNavigate={handleNavigate} />)}
</Box>
</Box>
)}
{/* Agent critiques */}
{report.agent_critiques.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Box onClick={() => setCritiquesOpen(!critiquesOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
Agent Critiques ({report.agent_critiques.length})
</Typography>
{critiquesOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
<Collapse in={critiquesOpen}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{report.agent_critiques.map((c, i) => (
<AgentCritiqueCard key={i} critique={c} onNavigate={handleNavigate} />
))}
</Box>
</Collapse>
</Box>
)}
{/* Coverage gaps */}
{hasGaps && (
<Box sx={{ mb: 1.5 }}>
<Box onClick={() => setGapsOpen(!gapsOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,152,0,0.7)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
Coverage Gaps ({report.coverage_gaps.length})
</Typography>
{gapsOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
<Collapse in={gapsOpen}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{report.coverage_gaps.map((g, i) => <GapRow key={i} gap={g} onNavigate={handleNavigate} />)}
</Box>
</Collapse>
</Box>
)}
{/* Overlaps */}
{hasOverlaps && (
<Box sx={{ mb: 1.5 }}>
<Box onClick={() => setOverlapsOpen(!overlapsOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(33,150,243,0.7)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
Overlaps ({report.overlaps.length})
</Typography>
{overlapsOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
<Collapse in={overlapsOpen}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{report.overlaps.map((o, i) => <OverlapRow key={i} overlap={o} onNavigate={handleNavigate} />)}
</Box>
</Collapse>
</Box>
)}
{/* Auto-collapse hint */}
{!critiquesOpen && !gapsOpen && !overlapsOpen && !hasAlerts && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', fontSize: 9, mt: 0.5 }}>
Tap sections above to expand details
</Typography>
)}
</Box>
);
};
export default QualityAuditPanel;

View File

@@ -0,0 +1,215 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Chip,
LinearProgress,
Button,
} from '@mui/material';
import {
TrendingUp as TrendIcon,
Whatshot as HotIcon,
EditNote as EditNoteIcon,
} from '@mui/icons-material';
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface TrendOpportunity {
trend_id: string;
topic: string;
headline: string;
source: string;
urgency: string;
impact_score: number;
current_coverage: number;
recommendation: string;
suggested_angle: string;
detected_at: string;
}
interface TrendSignalPayload {
opportunities: TrendOpportunity[];
total_detected: number;
scan_timestamp: string;
}
const urgencyColor = (u: string) => {
if (u === 'critical') return '#f44336';
if (u === 'high') return '#ff9800';
return '#4caf50';
};
const recommendationLabel = (r: string) => {
if (r === 'create_content' || r === 'create') return 'Create Content';
return r.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
const TrendSignalsPanel: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
const navigate = useNavigate();
const signals = useMemo<TrendSignalPayload | null>(() => {
const evt = events.find((e) => e.event_type === 'trend_signals');
if (!evt?.payload) return null;
return (typeof evt.payload === 'string' ? JSON.parse(evt.payload) : evt.payload) as TrendSignalPayload;
}, [events]);
if (!signals?.opportunities?.length) return null;
const handleCreateContent = (opp: TrendOpportunity) => {
navigate('/blog-writer', {
state: {
trendTopic: opp.topic,
trendHeadline: opp.headline,
trendAngle: opp.suggested_angle,
trendUrgency: opp.urgency,
trendImpact: opp.impact_score,
source: 'trend_signals',
},
});
};
return (
<Box
sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
backdropFilter: 'blur(22px)',
WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
p: 2.5,
mb: 2,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendIcon sx={{ fontSize: 18, color: '#ff9800' }} />
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
Trend Signals
</Typography>
<Chip
label={`${signals.total_detected} detected`}
size="small"
sx={{ ml: 'auto', height: 20, fontSize: 10, fontWeight: 600, bgcolor: 'rgba(255,152,0,0.15)', color: '#ff9800' }}
/>
</Box>
{/* Opportunities */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{signals.opportunities.map((opp, i) => (
<Box
key={opp.trend_id || i}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
}}
>
{/* Headline + urgency */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.75 }}>
<HotIcon sx={{ fontSize: 14, color: urgencyColor(opp.urgency), mt: 0.25, flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, lineHeight: 1.3 }}>
{opp.headline || opp.topic}
</Typography>
</Box>
<Chip
label={opp.urgency}
size="small"
sx={{
height: 18,
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
bgcolor: `${urgencyColor(opp.urgency)}22`,
color: urgencyColor(opp.urgency),
flexShrink: 0,
}}
/>
</Box>
{/* Angle */}
{opp.suggested_angle && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', mb: 0.75, lineHeight: 1.4, pl: 3 }}>
{opp.suggested_angle}
</Typography>
)}
{/* Metrics row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pl: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Impact
</Typography>
<LinearProgress
variant="determinate"
value={opp.impact_score * 100}
sx={{
height: 3,
borderRadius: 1.5,
bgcolor: 'rgba(255,255,255,0.08)',
'& .MuiLinearProgress-bar': {
bgcolor: opp.impact_score > 0.7 ? '#ff9800' : '#8b9cf7',
},
}}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Coverage
</Typography>
<LinearProgress
variant="determinate"
value={opp.current_coverage * 100}
sx={{
height: 3,
borderRadius: 1.5,
bgcolor: 'rgba(255,255,255,0.08)',
'& .MuiLinearProgress-bar': {
bgcolor: opp.current_coverage > 0.7 ? '#4caf50' : opp.current_coverage > 0.3 ? '#ff9800' : '#8b9cf7',
},
}}
/>
</Box>
<Chip
label={recommendationLabel(opp.recommendation)}
size="small"
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}
/>
</Box>
{/* Action button */}
{(opp.recommendation === 'create_content' || opp.recommendation === 'create') && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.75, pl: 3 }}>
<Button
size="small"
variant="outlined"
startIcon={<EditNoteIcon sx={{ fontSize: 14 }} />}
onClick={() => handleCreateContent(opp)}
sx={{
textTransform: 'none',
fontSize: 10,
py: 0.25,
px: 1,
borderColor: 'rgba(255,152,0,0.3)',
color: '#ff9800',
'&:hover': {
borderColor: '#ff9800',
bgcolor: 'rgba(255,152,0,0.08)',
},
}}
>
Create content from this trend
</Button>
</Box>
)}
</Box>
))}
</Box>
</Box>
);
};
export default TrendSignalsPanel;

View File

@@ -1,20 +1,30 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
import { createClient, OAuthStrategy } from '@wix/sdk';
import { apiClient } from '../../api/client';
import { WIX_CLIENT_ID } from '../../config/wixConfig';
import { storeEncrypted } from '../../utils/wixTokenStorage';
const FALLBACK_ORIGIN = 'http://localhost:3000';
const WixCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
const run = async () => {
try {
const wixClient = createClient({ auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
const { code, state, error, errorDescription } = wixClient.auth.parseFromUrl();
if (error) {
setError(`${error}: ${errorDescription || ''}`);
const wixClient = createClient({ auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
const { code, state, error: wixError, errorDescription } = wixClient.auth.parseFromUrl();
if (wixError) {
setError(`${wixError}: ${errorDescription || ''}`);
return;
}
if (!code) {
setError('Missing authorization code in URL');
return;
}
@@ -37,29 +47,54 @@ const WixCallbackPage: React.FC = () => {
return;
}
console.log('[WixCallbackPage] oauthData keys:', Object.keys(oauthData || {}));
let accessToken: string | null = null;
let refreshToken: string | null = null;
let expiresIn: number | null = null;
let siteInfo: any = null;
// === PRIMARY PATH: Client-side exchange (Wix SDK has internal code_verifier) ===
try {
const response = await apiClient.post('/api/wix/auth/callback', { code, state });
if (response.data.success) {
const { tokens, site_info } = response.data;
accessToken = tokens?.access_token || tokens?.accessToken?.value || null;
siteInfo = site_info || null;
} else {
throw new Error(response.data.message || 'Connection failed');
}
} catch (backendError: any) {
console.error('Backend exchange failed, falling back to client-side:', backendError);
console.log('[WixCallbackPage] Attempting client-side token exchange...');
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
wixClient.auth.setTokens(tokens);
accessToken = (tokens as any)?.accessToken?.value || (tokens as any)?.access_token || null;
refreshToken = (tokens as any)?.refreshToken?.value || (tokens as any)?.refresh_token || null;
expiresIn = (tokens as any)?.accessToken?.expiresAt
? Math.floor(((tokens as any).accessToken.expiresAt - Date.now()) / 1000)
: (tokens as any)?.expires_in || null;
console.log('[WixCallbackPage] Client-side exchange OK. accessToken present:', !!accessToken);
} catch (clientError: any) {
console.error('[WixCallbackPage] Client-side exchange failed:', clientError);
setError(`Client-side token exchange failed: ${clientError?.message || clientError}`);
return;
}
// Store in current origin's storage (may be ngrok — not accessible from localhost,
// but useful if the callback runs on the same origin as the app)
// === SECONDARY PATH: Send token to backend for storage ===
if (accessToken) {
try {
console.log('[WixCallbackPage] Sending token to backend for storage...');
const response = await apiClient.post('/api/wix/auth/callback', {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresIn,
token_type: 'Bearer',
});
if (response.data.success) {
siteInfo = response.data.site_info || null;
console.log('[WixCallbackPage] Backend stored token successfully');
} else {
console.warn('[WixCallbackPage] Backend store returned:', response.data.message);
}
} catch (backendError: any) {
console.warn('[WixCallbackPage] Backend store failed (non-fatal):', backendError);
}
}
// Store in current origin's storage
try {
if (accessToken) localStorage.setItem('wix_access_token', accessToken);
if (accessToken) await storeEncrypted('wix_access_token', accessToken);
} catch {}
localStorage.setItem('wix_connected', 'true');
sessionStorage.setItem('wix_connected', 'true');
@@ -69,9 +104,7 @@ const WixCallbackPage: React.FC = () => {
if (state) sessionStorage.removeItem(`wix_oauth_data_${state}`);
localStorage.removeItem('wix_oauth_data');
// CRITICAL: Put access_token + site_info into window.name so it survives
// the cross-origin redirect (ngrok → localhost). window.name persists
// across same-tab navigations even when the origin changes.
// Persist across cross-origin redirect via window.name
try {
const payload = { access_token: accessToken, site_info: siteInfo };
(window as any).name = `WIX_RESULT::${btoa(JSON.stringify(payload))}`;
@@ -90,9 +123,7 @@ const WixCallbackPage: React.FC = () => {
localStorage.setItem('blogwriter_current_phase', 'publish');
localStorage.setItem('blogwriter_user_selected_phase', 'true');
// Build redirect URL. oauthData.redirect_to was set by WixConnectModal
// to the user's actual origin (e.g. http://localhost:3000/blog-writer#publish).
// sessionStorage is per-origin so wix_oauth_redirect may be null on ngrok.
// Build redirect URL
let redirectUrl = oauthData?.redirect_to || sessionStorage.getItem('wix_oauth_redirect');
if (redirectUrl) {
sessionStorage.removeItem('wix_oauth_redirect');
@@ -104,7 +135,6 @@ const WixCallbackPage: React.FC = () => {
redirectUrl = `${redirectUrl}?wix_connected=true`;
}
} else {
// Fallback: construct localhost URL
redirectUrl = `${FALLBACK_ORIGIN}/blog-writer?wix_connected=true#publish`;
}

View File

@@ -18,6 +18,7 @@ import {
import { apiClient } from '../../api/client';
import { createClient, OAuthStrategy } from '@wix/sdk';
import { categories as blogCategoriesModule, tags as blogTagsModule } from '@wix/blog';
import { WIX_CLIENT_ID, getWixRedirectOrigin } from '../../config/wixConfig';
interface WixConnectionStatus {
connected: boolean;
@@ -108,11 +109,10 @@ This integration opens up new possibilities for content creators who want to lev
setLoading(true);
try {
const wixClient = createClient({
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID })
});
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
const redirectOrigin = getWixRedirectOrigin();
const redirectUri = `${redirectOrigin}/wix/callback`;
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
// Use sessionStorage to ensure data is scoped to this tab/session
@@ -131,7 +131,7 @@ This integration opens up new possibilities for content creators who want to lev
const tokensRaw = sessionStorage.getItem('wix_tokens');
if (!tokensRaw) throw new Error('Missing Wix tokens');
const tokens = JSON.parse(tokensRaw);
const wixClient = createClient({ modules: { categories: blogCategoriesModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
const wixClient = createClient({ modules: { categories: blogCategoriesModule }, auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
wixClient.auth.setTokens(tokens);
const result = await wixClient.categories.queryCategories().find();
const cats = (result.items || []).map((c: any) => ({ id: c.id, name: c.name || '', description: c.description || '' }));
@@ -147,7 +147,7 @@ This integration opens up new possibilities for content creators who want to lev
const tokensRaw = sessionStorage.getItem('wix_tokens');
if (!tokensRaw) throw new Error('Missing Wix tokens');
const tokens = JSON.parse(tokensRaw);
const wixClient = createClient({ modules: { tags: blogTagsModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
const wixClient = createClient({ modules: { tags: blogTagsModule }, auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
wixClient.auth.setTokens(tokens);
const result = await wixClient.tags.queryTags().find();
const t = (result.items || []).map((it: any) => ({ id: it.id, label: it.label || '' }));

View File

@@ -0,0 +1,17 @@
export const WIX_CLIENT_ID = process.env.REACT_APP_WIX_CLIENT_ID || '';
export const WIX_NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || '';
export function getWixRedirectOrigin(): string {
if (typeof window === 'undefined') return WIX_NGROK_ORIGIN;
return window.location.origin.includes('localhost') && WIX_NGROK_ORIGIN
? WIX_NGROK_ORIGIN
: window.location.origin;
}
export function getWixTrustedOrigins(): string[] {
if (typeof window === 'undefined') return WIX_NGROK_ORIGIN ? [WIX_NGROK_ORIGIN] : [];
const origins = [window.location.origin];
if (WIX_NGROK_ORIGIN) origins.push(WIX_NGROK_ORIGIN);
return origins;
}

View File

@@ -95,11 +95,14 @@ const restoreInitialState = () => {
seoAnalysis = readLS<BlogSEOAnalyzeResponse | null>('blog_seo_analysis', null);
seoMetadata = readLS<BlogSEOMetadataResponse | null>('blog_seo_metadata', null);
// Restore section images
// Restore section images (log only once per session, not on every hook mount)
const savedSectionImages = readLS<Record<string, string> | null>('blog_section_images', null);
if (savedSectionImages && Object.keys(savedSectionImages).length > 0) {
sectionImages = savedSectionImages;
console.log(`[SectionImages] Restored ${Object.keys(sectionImages).length} images from localStorage`);
if (!(window as any).__sectionImagesLogged) {
console.log(`[SectionImages] Restored ${Object.keys(sectionImages).length} images from localStorage`);
(window as any).__sectionImagesLogged = true;
}
}
} catch (error) {
console.error('Error during initial state restoration:', error);
@@ -137,6 +140,7 @@ export const useBlogWriterState = () => {
const [seoAnalysis, setSeoAnalysis] = useState<BlogSEOAnalyzeResponse | null>(initialState.seoAnalysis);
const [genMode, setGenMode] = useState<'draft' | 'polished'>('polished');
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(initialState.seoMetadata);
const [introduction, setIntroduction] = useState<string>(localStorage.getItem('blog_introduction') || '');
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
const [outlineTaskId, setOutlineTaskId] = useState<string | null>(null);
const [flowAnalysisCompleted, setFlowAnalysisCompleted] = useState<boolean>(false);
@@ -246,15 +250,32 @@ export const useBlogWriterState = () => {
useEffect(() => {
try {
if (Object.keys(sectionImages).length > 0) {
localStorage.setItem('blog_section_images', JSON.stringify(sectionImages));
const serialized = JSON.stringify(sectionImages);
// Warn if approaching localStorage quota (~5MB)
if (serialized.length > 4_000_000) {
console.warn(`[SectionImages] Approaching localStorage quota: ${(serialized.length / 1024 / 1024).toFixed(1)}MB`);
}
localStorage.setItem('blog_section_images', serialized);
} else {
localStorage.removeItem('blog_section_images');
// Only remove if we have previously saved images (avoid clearing on transient empty state)
if (localStorage.getItem('blog_section_images')) {
localStorage.removeItem('blog_section_images');
}
}
} catch (e) {
console.warn('[SectionImages] Failed to persist to localStorage via effect:', e);
}
}, [sectionImages]);
// Persist introduction to localStorage whenever it changes
useEffect(() => {
try {
if (introduction) {
localStorage.setItem('blog_introduction', introduction);
}
} catch {}
}, [introduction]);
// Persist sections to blogWriterCache whenever they change
useEffect(() => {
const outlineIds = outline.map(s => String(s.id));
@@ -410,6 +431,7 @@ export const useBlogWriterState = () => {
titleOptions,
selectedTitle,
sections,
introduction,
seoAnalysis,
genMode,
seoMetadata,
@@ -433,6 +455,7 @@ export const useBlogWriterState = () => {
setTitleOptions,
setSelectedTitle,
setSections,
setIntroduction,
setSeoAnalysis,
setGenMode,
setSeoMetadata,

View File

@@ -1,6 +1,16 @@
import { useCallback } from 'react';
import { marked } from 'marked';
import { BlogOutlineSection } from '../services/blogWriterApi';
marked.use({
gfm: true,
breaks: false,
pedantic: false,
});
const countWords = (text: string): number =>
text.split(/\s+/).filter(Boolean).length;
export const useMarkdownProcessor = (
outline: BlogOutlineSection[],
sections: Record<string, string>
@@ -10,89 +20,27 @@ export const useMarkdownProcessor = (
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
}, [outline, sections]);
const convertMarkdownToHTML = useCallback((md: string) => {
const convertMarkdownToHTML = useCallback((md: string): string => {
if (!md) return '';
let html = md;
// Headings (must be first, before other replacements)
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
// Bold and Italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links [text](url) - handle both http and data:image URLs
html = html.replace(/\[(.+?)\]\((.+?)\)/g, (match, text, url) => {
const safeUrl = url.replace(/"/g, '&quot;');
if (url.startsWith('data:image') || url.startsWith('http')) {
return `<img src="${safeUrl}" alt="${text}" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />`;
}
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" style="color:#4f46e5;text-decoration:underline;">${text}</a>`;
});
// Images ![alt](url) - explicit image syntax
html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />');
// Blockquotes
html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left:4px solid #e5e7eb;margin:1rem 0;padding:0.5rem 1rem;background:#f9fafb;color:#6b7280;font-style:italic;">$1</blockquote>');
// Inline code
html = html.replace(/`(.+?)`/g, '<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:monospace;font-size:0.9em;color:#dc2626;">$1</code>');
// Horizontal rules
html = html.replace(/^-{3,}$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:1.5rem 0;" />');
// Unordered lists (- item or * item)
html = html.replace(/^[-*] (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
// Wrap consecutive <li> tags in <ul>
html = html.replace(/(<li style="margin-bottom:0.5rem;">.+<\/li>\n?)+/g, (match) => {
return `<ul style="padding-left:1.5rem;margin:1rem 0;list-style-type:disc;">${match}</ul>`;
});
// Ordered lists (1. item, 2. item, etc.)
html = html.replace(/^\d+\. (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
// Wrap consecutive <li> tags in <ol> (simplified - assumes ordered lists come after unordered processing)
// Paragraphs (double newlines)
html = html.replace(/\n\n/g, '</p><p>');
html = `<p>${html}</p>`;
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '');
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
html = html.replace(/<p>(<ul>)/g, '$1');
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
html = html.replace(/<p>(<ol>)/g, '$1');
html = html.replace(/(<\/ol>)<\/p>/g, '$1');
html = html.replace(/<p>(<blockquote>)/g, '$1');
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
html = html.replace(/<p>(<hr)/g, '$1');
html = html.replace(/(<img[^>]*\/>)<\/p>/g, '$1');
html = html.replace(/<p>(<img)/g, '$1');
return html;
try {
const rendered = marked.parse(md);
const html = typeof rendered === 'string' ? rendered : '';
return html.replace(/<table>/g, '<div class="table-wrapper"><table>').replace(/<\/table>/g, '</table></div>');
} catch {
return `<p style="color:#991b1b;">Could not render this section. Unexpected markdown syntax encountered.</p>`;
}
}, []);
const getTotalWords = useCallback(() => {
const fullMarkdown = buildFullMarkdown();
return fullMarkdown.split(/\s+/).filter(word => word.length > 0).length;
}, [buildFullMarkdown]);
const getTotalWords = useCallback(() => countWords(buildFullMarkdown()), [buildFullMarkdown]);
const getSectionWordCount = useCallback((sectionId: string) => {
const content = sections[sectionId] || '';
return content.split(/\s+/).filter(word => word.length > 0).length;
}, [sections]);
const getSectionWordCount = useCallback((sectionId: string) => countWords(sections[sectionId] || ''), [sections]);
const getOutlineStats = useCallback(() => {
const totalWords = getTotalWords();
const totalSections = outline.length;
const totalSubheadings = outline.reduce((sum, section) => sum + section.subheadings.length, 0);
const totalKeyPoints = outline.reduce((sum, section) => sum + section.key_points.length, 0);
return {
totalWords,
totalSections,

View File

@@ -39,6 +39,15 @@ export const usePhaseNavigation = (
initialPhase: adjustedInitialPhase,
});
// Read publish completion flag (persists across refreshes)
const publishCompleted = ((): boolean => {
try {
return localStorage.getItem('blog_publish_completed') === 'true';
} catch {
return false;
}
})();
// Determine phase states based on current data
const phases = useMemo((): Phase[] => {
const researchCompleted = !!research;
@@ -88,13 +97,13 @@ export const usePhaseNavigation = (
name: 'Publish',
icon: '🚀',
description: 'Publish your blog post',
completed: false,
completed: publishCompleted,
current: core.currentPhase === 'publish',
disabled: !seoCompleted,
disabled: !seoCompleted && !publishCompleted,
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase]);
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, publishCompleted]);
// Shared validation: redirect if current phase is disabled
usePhaseValidation(
@@ -117,6 +126,11 @@ export const usePhaseNavigation = (
return;
}
// If publish was already completed, don't auto-nav away from it
if (publishCompleted && core.currentPhase === 'publish') {
return;
}
const canNavigateTo = (phaseId: string): boolean => {
const phase = phases.find(p => p.id === phaseId);
return !!phase && !phase.disabled;
@@ -149,7 +163,7 @@ export const usePhaseNavigation = (
core.setCurrentPhase('publish');
}
}
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, core.userSelectedPhase, phases]);
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, core.userSelectedPhase, phases, publishCompleted]);
const navigateToPhase = useCallback(
(phaseId: string) => core.navigateToPhase(phaseId, phases),

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiClient } from '../api/client';
import { BlogSEOMetadataResponse } from '../services/blogWriterApi';
import { storeEncrypted, readEncrypted, storeEncryptedSync } from '../utils/wixTokenStorage';
import { markConnectionHandled, isAlreadyHandled } from '../utils/wixConnectionDedup';
export interface WixStatus {
connected: boolean;
@@ -14,6 +16,49 @@ export interface WixPublishResult {
post_id?: string;
message: string;
action_required?: string;
warning?: string;
}
const WIX_TOKEN_KEY = 'wix_access_token';
const WIX_CONNECTED_KEY = 'wix_connected';
const CONTENT_MAX_LENGTH = 50000;
const CONTENT_WARNING_LENGTH = 30000;
function validatePublishContent(content: string): { valid: boolean; warning?: string } {
if (!content || !content.trim()) {
return { valid: false, warning: 'Cannot publish: content is empty.' };
}
const stripped = content.trim();
if (stripped.length < 10) {
return { valid: false, warning: 'Content is too short to publish. Write more before publishing.' };
}
let boldOpen = 0;
let i = 0;
while (i < stripped.length) {
if (i < stripped.length - 1 && stripped[i] === '*' && stripped[i + 1] === '*') {
boldOpen++;
i += 2;
continue;
}
i++;
}
if (boldOpen % 2 !== 0) {
return { valid: false, warning: 'Content has an unmatched ** (bold marker). Please fix formatting before publishing.' };
}
let codeTicks = 0;
const codeMatch = stripped.match(/```/g);
if (codeMatch) codeTicks = codeMatch.length;
if (codeTicks % 2 !== 0) {
return { valid: false, warning: 'Content has an unmatched ``` (code block marker). Please fix formatting before publishing.' };
}
if (stripped.length > CONTENT_MAX_LENGTH) {
return { valid: false, warning: `Content is ${Math.round(stripped.length / 1000)}K characters — maximum is ${CONTENT_MAX_LENGTH / 1000}K. Please shorten your content.` };
}
if (stripped.length > CONTENT_WARNING_LENGTH) {
return { valid: true, warning: `Content is ${Math.round(stripped.length / 1000)}K characters. Very long posts may take longer to publish on Wix.` };
}
return { valid: true };
}
export function useWixPublish() {
@@ -23,26 +68,42 @@ export function useWixPublish() {
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
const pendingPublishRef = useRef<(() => Promise<WixPublishResult>) | null>(null);
const clearStaleWixState = useCallback(() => {
try {
localStorage.removeItem(WIX_CONNECTED_KEY);
localStorage.removeItem(`wix_ek_${WIX_TOKEN_KEY}`);
sessionStorage.removeItem(WIX_CONNECTED_KEY);
sessionStorage.removeItem('wix_tokens');
sessionStorage.removeItem('wix_site_info');
window.name = '';
} catch {}
}, []);
const checkWixStatus = useCallback(async () => {
setCheckingWix(true);
try {
// 1. Cross-tab handoff from OAuth callback (ngrok → localhost redirect)
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
try {
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
if (payload.access_token) {
localStorage.setItem('wix_access_token', payload.access_token);
await storeEncrypted(WIX_TOKEN_KEY, payload.access_token);
}
localStorage.setItem('wix_connected', 'true');
sessionStorage.setItem('wix_connected', 'true');
markConnectionHandled();
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
sessionStorage.setItem(WIX_CONNECTED_KEY, 'true');
window.name = '';
setWixStatus({ connected: true, has_permissions: true, site_info: payload.site_info });
return;
} catch {}
}
// 2. PRIMARY: Ask backend — it actually validates the DB token against Wix APIs
try {
const resp = await apiClient.get('/api/wix/connection/status');
if (resp.data?.connected) {
// Backend says token is valid — sync local state and show connected
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
setWixStatus({
connected: true,
has_permissions: resp.data.has_permissions ?? true,
@@ -50,24 +111,27 @@ export function useWixPublish() {
});
return;
}
} catch {}
if (localStorage.getItem('wix_connected') === 'true') {
setWixStatus({ connected: true, has_permissions: true });
// Backend says NOT connected (401 or valid response with connected:false)
// → token expired / revoked / missing → clear all local state
clearStaleWixState();
setWixStatus({ connected: false, has_permissions: false });
return;
} catch (err: any) {
// Backend error (network, 500, etc.) — can't determine status
// Fall through to localStorage hint only if we have no other info
console.warn('[Wix] Backend connection check failed:', err?.message || err);
}
if (sessionStorage.getItem('wix_connected') === 'true') {
setWixStatus({ connected: true, has_permissions: true });
return;
}
// 3. FALLBACK: localStorage is only a hint, never authoritative
const localConnected = localStorage.getItem(WIX_CONNECTED_KEY) === 'true';
const sessionConnected = sessionStorage.getItem(WIX_CONNECTED_KEY) === 'true';
const urlConnected = new URLSearchParams(window.location.search).get('wix_connected') === 'true';
const params = new URLSearchParams(window.location.search);
if (params.get('wix_connected') === 'true') {
localStorage.setItem('wix_connected', 'true');
sessionStorage.setItem('wix_connected', 'true');
if (localConnected || sessionConnected || urlConnected) {
// We have a hint that user was connected, but backend couldn't confirm
// Show as connected but with warning — user may need to reconnect
console.warn('[Wix] Showing cached connection state — backend validation failed. User may need to reconnect.');
setWixStatus({ connected: true, has_permissions: true });
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
return;
}
@@ -77,7 +141,7 @@ export function useWixPublish() {
} finally {
setCheckingWix(false);
}
}, []);
}, [clearStaleWixState]);
useEffect(() => {
checkWixStatus();
@@ -85,21 +149,25 @@ export function useWixPublish() {
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === 'wix_connected' && e.newValue === 'true') {
if (isAlreadyHandled()) return;
if (e.key === WIX_CONNECTED_KEY && e.newValue === 'true') {
markConnectionHandled();
setWixStatus({ connected: true, has_permissions: true });
setShowWixConnectModal(false);
}
if (e.key === 'wix_access_token' && e.newValue) {
if (e.key === `wix_ek_${WIX_TOKEN_KEY}` && e.newValue) {
setWixStatus(prev => prev ? prev : { connected: true, has_permissions: true });
}
};
window.addEventListener('storage', handler);
const msgHandler = (e: MessageEvent) => {
if (isAlreadyHandled()) return;
if (e.data?.type === 'WIX_OAUTH_SUCCESS' && e.data?.success) {
if (e.data.access_token) localStorage.setItem('wix_access_token', e.data.access_token);
localStorage.setItem('wix_connected', 'true');
sessionStorage.setItem('wix_connected', 'true');
markConnectionHandled();
if (e.data.access_token) storeEncryptedSync(WIX_TOKEN_KEY, e.data.access_token);
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
sessionStorage.setItem(WIX_CONNECTED_KEY, 'true');
setWixStatus({ connected: true, has_permissions: true, site_info: e.data.site_info });
setShowWixConnectModal(false);
}
@@ -132,33 +200,42 @@ export function useWixPublish() {
}
try {
// Include access_token as fallback. The backend DB may not have tokens
// if the OAuth callback ran in a new tab where Clerk wasn't initialized.
// Tokens may be in sessionStorage (same-tab) or localStorage (cross-tab).
let accessToken: string | undefined;
try {
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
const validation = validatePublishContent(content);
if (!validation.valid) {
return { success: false, message: validation.warning || 'Content validation failed.' };
}
let frontendAccessToken: string | undefined;
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
try {
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
accessToken = payload.access_token || undefined;
if (payload.access_token) localStorage.setItem('wix_access_token', payload.access_token);
if (payload.access_token) {
await storeEncrypted(WIX_TOKEN_KEY, payload.access_token);
frontendAccessToken = payload.access_token;
}
window.name = '';
}
} catch {}
if (!accessToken) {
} catch {}
}
if (!frontendAccessToken) {
try {
const raw = sessionStorage.getItem('wix_tokens');
if (raw) {
const parsed = JSON.parse(raw);
accessToken = parsed.accessToken?.value || parsed.access_token || undefined;
frontendAccessToken = parsed.accessToken?.value || parsed.access_token || undefined;
}
} catch {}
}
if (!accessToken) {
if (!frontendAccessToken) {
try {
accessToken = localStorage.getItem('wix_access_token') || undefined;
frontendAccessToken = (await readEncrypted(WIX_TOKEN_KEY)) || undefined;
} catch {}
}
console.log('[WixPublish] Publishing — backend DB is authoritative token source; frontend token sent as fallback only.');
const response = await apiClient.post('/api/wix/publish', {
title,
content,
@@ -166,11 +243,13 @@ export function useWixPublish() {
category_names: metadata?.blog_categories || [],
tag_names: metadata?.blog_tags || [],
publish: true,
...(accessToken ? { access_token: accessToken } : {}),
...(frontendAccessToken ? { access_token: frontendAccessToken } : {}),
seo_metadata: metadata ? {
seo_title: metadata.seo_title,
meta_description: metadata.meta_description,
focus_keyword: metadata.focus_keyword,
url_slug: metadata.url_slug,
blog_categories: metadata.blog_categories || [],
blog_tags: metadata.blog_tags || [],
social_hashtags: metadata.social_hashtags || [],
open_graph: metadata.open_graph || {},
@@ -181,6 +260,7 @@ export function useWixPublish() {
if (response.data.success) {
const url = response.data.url;
const warning = response.data.warning;
return {
success: true,
url,
@@ -188,6 +268,7 @@ export function useWixPublish() {
message: url
? `Blog post published to Wix! View it here: ${url}`
: 'Blog post published successfully to Wix!',
...(warning ? { warning } : {}),
};
}
return {
@@ -196,6 +277,8 @@ export function useWixPublish() {
};
} catch (error: any) {
if (error.response?.status === 401 || error.response?.status === 403) {
clearStaleWixState();
setWixStatus({ connected: false, has_permissions: false });
pendingPublishRef.current = async () => publishToWix(content, metadata);
setShowWixConnectModal(true);
return {
@@ -209,7 +292,7 @@ export function useWixPublish() {
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`,
};
}
}, []);
}, [clearStaleWixState]);
const handleWixConnectionSuccess = useCallback(async () => {
await checkWixStatus();
@@ -243,5 +326,6 @@ export function useWixPublish() {
setShowWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
validateWixContent: validatePublishContent,
};
}
}

View File

@@ -102,9 +102,11 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
const messageHandler = (event: MessageEvent) => {
// Accept messages only from the popup we opened and from trusted origins
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || '';
const productionOrigin = 'https://alwrity-ai.vercel.app';
const trustedOrigins = [window.location.origin, ngrokOrigin, productionOrigin];
const trustedOrigins = [window.location.origin];
if (ngrokOrigin) trustedOrigins.push(ngrokOrigin);
trustedOrigins.push(productionOrigin);
if (event.source !== popup) return;
if (!trustedOrigins.includes(event.origin)) {

View File

@@ -1,64 +1,83 @@
import React from 'react';
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
import React, { useState } from 'react';
import { Box, Typography, Chip, Button } from '@mui/material';
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
import CommitteeSummary from '../components/TeamActivity/CommitteeSummary';
import CommitteeAuditTable from '../components/TeamActivity/CommitteeAuditTable';
import AlertBanner from '../components/TeamActivity/AlertBanner';
import AgentStatusPanel from '../components/TeamActivity/AgentStatusPanel';
import ActivityLog from '../components/TeamActivity/ActivityLog';
import QualityAuditPanel from '../components/TeamActivity/QualityAuditPanel';
import TrendSignalsPanel from '../components/TeamActivity/TrendSignalsPanel';
import AgentHelpModal from '../components/TeamActivity/AgentHelpModal';
const TeamActivityPage: React.FC = () => {
const { runs, events, alerts, approvals, connectionMode } = useAgentHuddleFeed();
const [auditMode, setAuditMode] = useState(false);
return (
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Team Activity</Typography>
<Chip label={connectionMode === 'sse' ? 'Live stream' : 'Polling fallback'} color={connectionMode === 'sse' ? 'success' : 'warning'} />
{/* Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)' }}>
Team Activity
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<Chip
label={connectionMode === 'sse' ? 'Live' : 'Polling'}
size="small"
sx={{ height: 24, fontSize: 11, fontWeight: 600, bgcolor: connectionMode === 'sse' ? 'rgba(76,175,80,0.15)' : 'rgba(255,152,0,0.15)', color: connectionMode === 'sse' ? '#4caf50' : '#ff9800' }}
/>
<Button
size="small"
variant={auditMode ? 'contained' : 'outlined'}
onClick={() => setAuditMode(!auditMode)}
sx={{
fontSize: 12,
fontWeight: 600,
textTransform: 'none',
borderRadius: 2,
...(auditMode
? {
background: 'linear-gradient(135deg, rgba(102,126,234,0.8), rgba(118,75,162,0.8))',
color: '#fff',
boxShadow: '0 4px 15px rgba(102,126,234,0.3)',
}
: {
color: 'rgba(255,255,255,0.7)',
borderColor: 'rgba(255,255,255,0.2)',
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
}),
}}
>
{auditMode ? '← Summary' : 'Advanced Audit ▾'}
</Button>
<AgentHelpModal />
</Box>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Run lifecycle updates</Typography>
<List dense>
{runs.slice(0, 20).map((run) => (
<ListItem key={run.id}><ListItemText primary={`${run.agent_type || 'agent'} · ${run.status}`} secondary={run.result_summary || run.finished_at || 'In progress'} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
{auditMode ? (
<CommitteeAuditTable events={events} />
) : (
<>
{/* 1. Alerts + Approvals need attention */}
<AlertBanner alerts={alerts} approvals={approvals} />
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>New events</Typography>
<List dense>
{events.slice(0, 20).map((event) => (
<ListItem key={event.id}><ListItemText primary={`${event.agent_type || 'agent'} · ${event.event_type}`} secondary={event.message || event.created_at} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
{/* 2. Committee decision brief */}
<CommitteeSummary events={events} />
<Grid item xs={12}><Divider /></Grid>
{/* 3. Quality audit (ContentGuardianAgent) */}
<QualityAuditPanel events={events} />
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Alert deltas</Typography>
<List dense>
{alerts.slice(0, 20).map((alert) => (
<ListItem key={alert.id}><ListItemText primary={alert.title || 'Alert'} secondary={alert.message} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
{/* 4. Trend signals (TrendSurferAgent) */}
<TrendSignalsPanel events={events} />
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Approval deltas</Typography>
<List dense>
{approvals.slice(0, 20).map((approval) => (
<ListItem key={approval.id}><ListItemText primary={`${approval.action_type || 'Action'} · ${approval.status}`} secondary={approval.created_at} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
</Grid>
{/* 5. Agent health at a glance */}
<AgentStatusPanel events={events} runs={runs} alerts={alerts} />
{/* 6. Raw activity feed (collapsed by default) */}
<ActivityLog runs={runs} events={events} />
</>
)}
</Box>
);
};

Some files were not shown because too many files have changed in this diff Show More