feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py
Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state
Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed
Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
466
backend/services/intelligence/agents/content_gap_radar_agent.py
Normal file
466
backend/services/intelligence/agents/content_gap_radar_agent.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user