Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
@@ -76,7 +76,8 @@ class ALwrityAgentOrchestrator:
|
||||
try:
|
||||
# Initialize shared LLM
|
||||
if TXTAI_AVAILABLE:
|
||||
self.llm = LLM(self.config.shared_llm)
|
||||
# Hardening: Explicitly set task to avoid 'text2text-generation' default failures
|
||||
self.llm = LLM(self.config.shared_llm, task="text-generation")
|
||||
else:
|
||||
self.llm = None
|
||||
|
||||
|
||||
@@ -181,7 +181,8 @@ class BaseALwrityAgent(ABC):
|
||||
try:
|
||||
if not self.llm:
|
||||
# Create new LLM if not provided
|
||||
raw_llm = LLM(model_name)
|
||||
# Hardening: Explicitly set task to avoid 'text2text-generation' default failures
|
||||
raw_llm = LLM(model_name, task="text-generation")
|
||||
# Wrap it
|
||||
self.llm = TrackingLLMWrapper(raw_llm, self.user_id, self.model_name)
|
||||
|
||||
@@ -906,6 +907,11 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
|
||||
"name": "task_delegator",
|
||||
"description": "Delegates specific tasks to specialized agents (content, competitor, seo, social)",
|
||||
"target": self._delegate_task_tool
|
||||
},
|
||||
{
|
||||
"name": "kickoff_gsc_first_pass",
|
||||
"description": "Kicks off first-pass execution by invoking SEO/Content default GSC plans",
|
||||
"target": self._kickoff_gsc_first_pass_tool
|
||||
}
|
||||
],
|
||||
max_iterations=15,
|
||||
@@ -924,7 +930,9 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
|
||||
Do not just plan; EXECUTE by delegating.
|
||||
|
||||
Always prioritize user goals and maintain safety constraints.
|
||||
Coordinate multi-agent responses to market changes effectively."""
|
||||
Coordinate multi-agent responses to market changes effectively.
|
||||
|
||||
First, call 'kickoff_gsc_first_pass' to ground the plan on live GSC signals."""
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1033,6 +1041,37 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _kickoff_gsc_first_pass_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Invoke SEO and Content agents' default GSC plans and combine results"""
|
||||
try:
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
payload = {"start_date": start_date, "end_date": end_date}
|
||||
results = {}
|
||||
combined_actions = []
|
||||
|
||||
seo = self.sub_agents.get("seo")
|
||||
if seo and hasattr(seo, "_default_seo_gsc_plan_tool"):
|
||||
plan = await seo._default_seo_gsc_plan_tool(payload)
|
||||
results["seo"] = plan
|
||||
combined_actions.extend(plan.get("actions", []) if isinstance(plan, dict) else [])
|
||||
|
||||
content = self.sub_agents.get("content")
|
||||
if content and hasattr(content, "_default_content_gsc_plan_tool"):
|
||||
plan = await content._default_content_gsc_plan_tool(payload)
|
||||
results["content"] = plan
|
||||
combined_actions.extend(plan.get("actions", []) if isinstance(plan, dict) else [])
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"invoked": list(results.keys()),
|
||||
"results": results,
|
||||
"combined_actions": combined_actions,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def _strategy_synthesizer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tool for synthesizing strategies"""
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ from loguru import logger
|
||||
from ..txtai_service import TxtaiIntelligenceService
|
||||
from services.intelligence.agents.core_agent_framework import BaseALwrityAgent, AgentAction
|
||||
from services.seo_tools.content_strategy_service import ContentStrategyService
|
||||
from services.analytics import PlatformAnalyticsService
|
||||
from services.intelligence.sif_agents import SharedLLMWrapper, LocalLLMWrapper
|
||||
try:
|
||||
from services.intelligence.sif_integration import SIFIntegrationService
|
||||
@@ -888,7 +889,37 @@ class ContentStrategyAgent(BaseALwrityAgent):
|
||||
"name": "sitemap_analyzer",
|
||||
"description": "Analyzes website structure and publishing velocity via sitemap",
|
||||
"target": self._sitemap_analyzer_tool
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gsc_low_ctr_queries",
|
||||
"description": "Returns low-CTR queries with evidence from cached GSC metrics",
|
||||
"target": self._cs_gsc_low_ctr_queries_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_striking_distance_queries",
|
||||
"description": "Returns striking-distance queries (positions ~8–20) with evidence",
|
||||
"target": self._cs_gsc_striking_distance_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_declining_queries",
|
||||
"description": "Returns period-over-period declining queries with evidence",
|
||||
"target": self._cs_gsc_declining_queries_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_low_ctr_pages",
|
||||
"description": "Returns low-CTR pages with top contributing queries",
|
||||
"target": self._cs_gsc_low_ctr_pages_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_cannibalization_candidates",
|
||||
"description": "Returns query→multiple-pages cannibalization candidates with target recommendation",
|
||||
"target": self._cs_gsc_cannibalization_candidates_tool
|
||||
},
|
||||
{
|
||||
"name": "default_content_gsc_plan",
|
||||
"description": "Runs a default first-pass plan using GSC signals (titles/meta, consolidation, refreshes)",
|
||||
"target": self._default_content_gsc_plan_tool
|
||||
},
|
||||
],
|
||||
max_iterations=8,
|
||||
system=self.get_effective_system_prompt(f"""You are the Content Strategy Agent for ALwrity user {self.user_id}.
|
||||
@@ -903,12 +934,153 @@ class ContentStrategyAgent(BaseALwrityAgent):
|
||||
- Performance-based content improvements
|
||||
|
||||
Use semantic analysis (SIF) and sitemap analysis to understand content context.
|
||||
Always prioritize user goals and maintain brand consistency."""
|
||||
Always prioritize user goals and maintain brand consistency.
|
||||
|
||||
In your first pass, call 'default_content_gsc_plan' to ground your actions on live GSC signals."""
|
||||
)
|
||||
)
|
||||
|
||||
# Tool Implementations
|
||||
|
||||
async def _cs_fetch_gsc_analytics(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
svc = PlatformAnalyticsService()
|
||||
data = await svc.get_comprehensive_analytics(self.user_id, platforms=["gsc"], start_date=start_date, end_date=end_date)
|
||||
gsc = data.get("gsc")
|
||||
if not gsc or gsc.status != "success":
|
||||
err = getattr(gsc, "error_message", None) if gsc else "No data"
|
||||
raise RuntimeError(f"GSC analytics unavailable: {err}")
|
||||
return {"metrics": gsc.metrics, "date_range": gsc.date_range}
|
||||
|
||||
async def _cs_gsc_low_ctr_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 100)); min_clicks = int(context.get("min_clicks", 10)); ctr_threshold = float(context.get("ctr_threshold", 1.5))
|
||||
start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
|
||||
tq = result["metrics"].get("top_queries", []) or []
|
||||
items = [
|
||||
{"query": r.get("query"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position")}
|
||||
for r in tq
|
||||
if (r.get("impressions", 0) >= min_impr and r.get("clicks", 0) >= min_clicks and float(r.get("ctr", 0.0)) < ctr_threshold)
|
||||
]
|
||||
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
|
||||
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
|
||||
except Exception as e:
|
||||
logger.error(f"cs low_ctr_queries failed: {e}"); return {"error": str(e)}
|
||||
|
||||
async def _cs_gsc_striking_distance_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 100)); start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
|
||||
tq = result["metrics"].get("top_queries", []) or []
|
||||
items = [
|
||||
{"query": r.get("query"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position")}
|
||||
for r in tq
|
||||
if (r.get("impressions", 0) >= min_impr and r.get("position") is not None and 8.0 <= float(r.get("position")) <= 20.0)
|
||||
]
|
||||
items.sort(key=lambda x: (x.get("position") if x.get("position") is not None else 999, -x.get("impressions", 0)))
|
||||
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
|
||||
except Exception as e:
|
||||
logger.error(f"cs striking_distance failed: {e}"); return {"error": str(e)}
|
||||
|
||||
async def _cs_gsc_declining_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10)); min_prev_clicks = int(context.get("min_prev_clicks", 10)); min_drop_pct = float(context.get("min_drop_pct", 30.0))
|
||||
start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
curr = await self._cs_fetch_gsc_analytics(start_date, end_date)
|
||||
curr_range = curr["date_range"]; s = curr_range.get("start"); e = curr_range.get("end")
|
||||
from datetime import datetime, timedelta; fmt = "%Y-%m-%d"
|
||||
sd = datetime.strptime(s, fmt) if s else datetime.utcnow() - timedelta(days=30); ed = datetime.strptime(e, fmt) if e else datetime.utcnow()
|
||||
days = max((ed - sd).days + 1, 1); prev_end = sd - timedelta(days=1); prev_start = prev_end - timedelta(days=days - 1)
|
||||
prev = await self._cs_fetch_gsc_analytics(prev_start.strftime(fmt), prev_end.strftime(fmt))
|
||||
curr_queries = {r.get("query"): r for r in (curr["metrics"].get("top_queries", []) or [])}
|
||||
prev_queries = {r.get("query"): r for r in (prev["metrics"].get("top_queries", []) or [])}
|
||||
items = []
|
||||
for q, prev_row in prev_queries.items():
|
||||
curr_row = curr_queries.get(q);
|
||||
if not curr_row: continue
|
||||
prev_clicks = int(prev_row.get("clicks", 0) or 0); curr_clicks = int(curr_row.get("clicks", 0) or 0)
|
||||
if prev_clicks >= min_prev_clicks and curr_clicks < prev_clicks:
|
||||
drop_pct = ((prev_clicks - curr_clicks) / prev_clicks) * 100.0
|
||||
if drop_pct >= min_drop_pct:
|
||||
items.append({"query": q, "prev_clicks": prev_clicks, "curr_clicks": curr_clicks, "drop_pct": round(drop_pct, 2)})
|
||||
items.sort(key=lambda x: (x.get("drop_pct", 0), x.get("prev_clicks", 0)), reverse=True)
|
||||
return {"items": items[:limit], "range": curr_range, "previous_range": prev["date_range"], "source": "gsc_cache"}
|
||||
except Exception as e:
|
||||
logger.error(f"cs declining_queries failed: {e}"); return {"error": str(e)}
|
||||
|
||||
async def _cs_gsc_low_ctr_pages_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 200)); ctr_threshold = float(context.get("ctr_threshold", 1.5))
|
||||
start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
|
||||
tp = result["metrics"].get("top_pages", []) or []
|
||||
items = []
|
||||
for r in tp:
|
||||
if (r.get("impressions", 0) >= min_impr and float(r.get("ctr", 0.0)) < ctr_threshold):
|
||||
items.append({"page": r.get("page"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position"), "evidence_queries": r.get("queries", [])[:5]})
|
||||
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
|
||||
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
|
||||
except Exception as e:
|
||||
logger.error(f"cs low_ctr_pages failed: {e}"); return {"error": str(e)}
|
||||
|
||||
async def _cs_gsc_cannibalization_candidates_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10)); start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
|
||||
candidates = result["metrics"].get("cannibalization", []) or []
|
||||
return {"items": candidates[:limit], "range": result["date_range"], "source": "gsc_cache"}
|
||||
except Exception as e:
|
||||
logger.error(f"cs cannibalization_candidates failed: {e}"); return {"error": str(e)}
|
||||
|
||||
async def _default_content_gsc_plan_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
start_date = context.get("start_date"); end_date = context.get("end_date")
|
||||
try:
|
||||
low_ctr_pages = await self._cs_gsc_low_ctr_pages_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
cannibals = await self._cs_gsc_cannibalization_candidates_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
striking = await self._cs_gsc_striking_distance_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
declining = await self._cs_gsc_declining_queries_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
|
||||
actions = []
|
||||
for p in low_ctr_pages.get("items", []):
|
||||
actions.append({
|
||||
"type": "improve_titles_meta",
|
||||
"target": p.get("page"),
|
||||
"reason": f"Low CTR {p.get('ctr')}% with {p.get('impressions')} impressions",
|
||||
"evidence": p.get("evidence_queries", [])
|
||||
})
|
||||
for c in cannibals.get("items", []):
|
||||
actions.append({
|
||||
"type": "consolidate/internal_link",
|
||||
"target": c.get("recommended_target_page"),
|
||||
"reason": f"Cannibalization on query '{c.get('query')}'",
|
||||
"pages": c.get("pages", [])
|
||||
})
|
||||
for q in striking.get("items", []):
|
||||
actions.append({
|
||||
"type": "refresh_content",
|
||||
"target": "query",
|
||||
"query": q.get("query"),
|
||||
"reason": f"Striking distance at position {q.get('position')} with {q.get('impressions')} impressions"
|
||||
})
|
||||
for q in declining.get("items", []):
|
||||
actions.append({
|
||||
"type": "refresh_content",
|
||||
"target": "query",
|
||||
"query": q.get("query"),
|
||||
"reason": f"Clicks decline {q.get('prev_clicks')}→{q.get('curr_clicks')} ({q.get('drop_pct')}%)"
|
||||
})
|
||||
|
||||
return {
|
||||
"plan_name": "Default Content Plan from GSC",
|
||||
"range": {"current": {"start": start_date, "end": end_date}},
|
||||
"actions": actions,
|
||||
"source": "gsc_cache",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"default_content_gsc_plan failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _sitemap_analyzer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sitemap analysis tool using ContentStrategyService"""
|
||||
website_url = context.get('website_url')
|
||||
@@ -1324,7 +1496,37 @@ class SEOOptimizationAgent(BaseALwrityAgent):
|
||||
"name": "query_seo_knowledge_base",
|
||||
"description": "Queries the SIF knowledge base for SEO dashboard data, GSC/Bing metrics, and semantic insights",
|
||||
"target": self._query_seo_knowledge_base_tool
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gsc_low_ctr_queries",
|
||||
"description": "Returns low-CTR queries with evidence from cached GSC metrics",
|
||||
"target": self._gsc_low_ctr_queries_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_striking_distance_queries",
|
||||
"description": "Returns striking-distance queries (positions ~8–20) with evidence",
|
||||
"target": self._gsc_striking_distance_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_declining_queries",
|
||||
"description": "Returns period-over-period declining queries with evidence",
|
||||
"target": self._gsc_declining_queries_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_low_ctr_pages",
|
||||
"description": "Returns low-CTR pages with top contributing queries",
|
||||
"target": self._gsc_low_ctr_pages_tool
|
||||
},
|
||||
{
|
||||
"name": "gsc_cannibalization_candidates",
|
||||
"description": "Returns query→multiple-pages cannibalization candidates with target recommendation",
|
||||
"target": self._gsc_cannibalization_candidates_tool
|
||||
},
|
||||
{
|
||||
"name": "default_seo_gsc_plan",
|
||||
"description": "Runs a default first-pass SEO plan using GSC signals (titles/meta, consolidation, refreshes)",
|
||||
"target": self._default_seo_gsc_plan_tool
|
||||
},
|
||||
],
|
||||
max_iterations=15,
|
||||
system=self.get_effective_system_prompt(f"""You are the SEO Optimization Agent for ALwrity user {self.user_id}.
|
||||
@@ -1340,6 +1542,7 @@ class SEOOptimizationAgent(BaseALwrityAgent):
|
||||
- Deep semantic search of SEO data (GSC, Bing, Audits)
|
||||
|
||||
Focus on high-impact, low-effort optimizations first.
|
||||
In your first pass, call 'default_seo_gsc_plan' to ground your actions on live GSC signals.
|
||||
Always maintain SEO best practices and user experience."""
|
||||
)
|
||||
)
|
||||
@@ -1666,6 +1869,223 @@ class SEOOptimizationAgent(BaseALwrityAgent):
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# GSC Insights Tools (Option B)
|
||||
async def _fetch_gsc_analytics(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
svc = PlatformAnalyticsService()
|
||||
data = await svc.get_comprehensive_analytics(self.user_id, platforms=["gsc"], start_date=start_date, end_date=end_date)
|
||||
gsc = data.get("gsc")
|
||||
if not gsc or gsc.status != "success":
|
||||
err = getattr(gsc, "error_message", None) if gsc else "No data"
|
||||
raise RuntimeError(f"GSC analytics unavailable: {err}")
|
||||
return {
|
||||
"metrics": gsc.metrics,
|
||||
"date_range": gsc.date_range
|
||||
}
|
||||
|
||||
async def _gsc_low_ctr_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10))
|
||||
min_impr = int(context.get("min_impressions", 100))
|
||||
min_clicks = int(context.get("min_clicks", 10))
|
||||
ctr_threshold = float(context.get("ctr_threshold", 1.5))
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._fetch_gsc_analytics(start_date, end_date)
|
||||
tq = result["metrics"].get("top_queries", []) or []
|
||||
items = [
|
||||
{
|
||||
"query": r.get("query"),
|
||||
"clicks": r.get("clicks", 0),
|
||||
"impressions": r.get("impressions", 0),
|
||||
"ctr": r.get("ctr", 0.0),
|
||||
"position": r.get("position")
|
||||
}
|
||||
for r in tq
|
||||
if (r.get("impressions", 0) >= min_impr and r.get("clicks", 0) >= min_clicks and float(r.get("ctr", 0.0)) < ctr_threshold)
|
||||
]
|
||||
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
|
||||
return {
|
||||
"items": items[:limit],
|
||||
"range": result["date_range"],
|
||||
"source": "gsc_cache"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"low_ctr_queries tool failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _gsc_striking_distance_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10))
|
||||
min_impr = int(context.get("min_impressions", 100))
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._fetch_gsc_analytics(start_date, end_date)
|
||||
tq = result["metrics"].get("top_queries", []) or []
|
||||
items = [
|
||||
{
|
||||
"query": r.get("query"),
|
||||
"clicks": r.get("clicks", 0),
|
||||
"impressions": r.get("impressions", 0),
|
||||
"ctr": r.get("ctr", 0.0),
|
||||
"position": r.get("position")
|
||||
}
|
||||
for r in tq
|
||||
if (r.get("impressions", 0) >= min_impr and r.get("position") is not None and 8.0 <= float(r.get("position")) <= 20.0)
|
||||
]
|
||||
items.sort(key=lambda x: (x.get("position") if x.get("position") is not None else 999, -x.get("impressions", 0)))
|
||||
return {
|
||||
"items": items[:limit],
|
||||
"range": result["date_range"],
|
||||
"source": "gsc_cache"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"striking_distance tool failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _gsc_declining_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10))
|
||||
min_prev_clicks = int(context.get("min_prev_clicks", 10))
|
||||
min_drop_pct = float(context.get("min_drop_pct", 30.0))
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
curr = await self._fetch_gsc_analytics(start_date, end_date)
|
||||
curr_range = curr["date_range"]
|
||||
s = curr_range.get("start")
|
||||
e = curr_range.get("end")
|
||||
from datetime import datetime, timedelta
|
||||
fmt = "%Y-%m-%d"
|
||||
sd = datetime.strptime(s, fmt) if s else datetime.utcnow() - timedelta(days=30)
|
||||
ed = datetime.strptime(e, fmt) if e else datetime.utcnow()
|
||||
days = max((ed - sd).days + 1, 1)
|
||||
prev_end = sd - timedelta(days=1)
|
||||
prev_start = prev_end - timedelta(days=days - 1)
|
||||
prev = await self._fetch_gsc_analytics(prev_start.strftime(fmt), prev_end.strftime(fmt))
|
||||
curr_queries = {r.get("query"): r for r in (curr["metrics"].get("top_queries", []) or [])}
|
||||
prev_queries = {r.get("query"): r for r in (prev["metrics"].get("top_queries", []) or [])}
|
||||
items = []
|
||||
for q, prev_row in prev_queries.items():
|
||||
curr_row = curr_queries.get(q)
|
||||
if not curr_row:
|
||||
continue
|
||||
prev_clicks = int(prev_row.get("clicks", 0) or 0)
|
||||
curr_clicks = int(curr_row.get("clicks", 0) or 0)
|
||||
if prev_clicks >= min_prev_clicks and curr_clicks < prev_clicks:
|
||||
drop_pct = ((prev_clicks - curr_clicks) / prev_clicks) * 100.0
|
||||
if drop_pct >= min_drop_pct:
|
||||
items.append({
|
||||
"query": q,
|
||||
"prev_clicks": prev_clicks,
|
||||
"curr_clicks": curr_clicks,
|
||||
"drop_pct": round(drop_pct, 2)
|
||||
})
|
||||
items.sort(key=lambda x: (x.get("drop_pct", 0), x.get("prev_clicks", 0)), reverse=True)
|
||||
return {
|
||||
"items": items[:limit],
|
||||
"range": curr_range,
|
||||
"previous_range": prev["date_range"],
|
||||
"source": "gsc_cache"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"declining_queries tool failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _gsc_low_ctr_pages_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10))
|
||||
min_impr = int(context.get("min_impressions", 200))
|
||||
ctr_threshold = float(context.get("ctr_threshold", 1.5))
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._fetch_gsc_analytics(start_date, end_date)
|
||||
tp = result["metrics"].get("top_pages", []) or []
|
||||
items = []
|
||||
for r in tp:
|
||||
if (r.get("impressions", 0) >= min_impr and float(r.get("ctr", 0.0)) < ctr_threshold):
|
||||
items.append({
|
||||
"page": r.get("page"),
|
||||
"clicks": r.get("clicks", 0),
|
||||
"impressions": r.get("impressions", 0),
|
||||
"ctr": r.get("ctr", 0.0),
|
||||
"position": r.get("position"),
|
||||
"evidence_queries": r.get("queries", [])[:5]
|
||||
})
|
||||
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
|
||||
return {
|
||||
"items": items[:limit],
|
||||
"range": result["date_range"],
|
||||
"source": "gsc_cache"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"low_ctr_pages tool failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _gsc_cannibalization_candidates_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
limit = int(context.get("limit", 10))
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
result = await self._fetch_gsc_analytics(start_date, end_date)
|
||||
candidates = result["metrics"].get("cannibalization", []) or []
|
||||
return {
|
||||
"items": candidates[:limit],
|
||||
"range": result["date_range"],
|
||||
"source": "gsc_cache"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"cannibalization_candidates tool failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _default_seo_gsc_plan_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
start_date = context.get("start_date")
|
||||
end_date = context.get("end_date")
|
||||
try:
|
||||
low_ctr_pages = await self._gsc_low_ctr_pages_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
cannibals = await self._gsc_cannibalization_candidates_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
striking = await self._gsc_striking_distance_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
declining = await self._gsc_declining_queries_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
|
||||
|
||||
actions = []
|
||||
for p in low_ctr_pages.get("items", []):
|
||||
actions.append({
|
||||
"type": "update_titles_meta",
|
||||
"target_page": p.get("page"),
|
||||
"justification": f"Low CTR {p.get('ctr')}% with {p.get('impressions')} impressions",
|
||||
"evidence": p.get("evidence_queries", [])
|
||||
})
|
||||
for c in cannibals.get("items", []):
|
||||
actions.append({
|
||||
"type": "consolidate/internal_link",
|
||||
"target_page": c.get("recommended_target_page"),
|
||||
"justification": f"Cannibalization on query '{c.get('query')}'",
|
||||
"pages": c.get("pages", [])
|
||||
})
|
||||
for q in striking.get("items", []):
|
||||
actions.append({
|
||||
"type": "refresh_content",
|
||||
"target": "query",
|
||||
"query": q.get("query"),
|
||||
"justification": f"Striking distance at position {q.get('position')} with {q.get('impressions')} impressions"
|
||||
})
|
||||
for q in declining.get("items", []):
|
||||
actions.append({
|
||||
"type": "refresh_content",
|
||||
"target": "query",
|
||||
"query": q.get("query"),
|
||||
"justification": f"Clicks decline {q.get('prev_clicks')}→{q.get('curr_clicks')} ({q.get('drop_pct')}%)"
|
||||
})
|
||||
|
||||
return {
|
||||
"plan_name": "Default SEO Plan from GSC",
|
||||
"range": {"current": {"start": start_date, "end": end_date}},
|
||||
"actions": actions,
|
||||
"source": "gsc_cache",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"default_seo_gsc_plan failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class SocialAmplificationAgent(BaseALwrityAgent):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user