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
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""Blog SEO Recommendation Applier
|
|
|
|
Applies actionable SEO recommendations to existing blog content using the
|
|
provider-agnostic `llm_text_gen` dispatcher. Ensures GPT_PROVIDER parity.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Dict, Any, List
|
|
from utils.logger_utils import get_service_logger
|
|
|
|
from services.llm_providers.main_text_generation import llm_text_gen
|
|
|
|
|
|
logger = get_service_logger("blog_seo_recommendation_applier")
|
|
|
|
|
|
class BlogSEORecommendationApplier:
|
|
"""Apply actionable SEO recommendations to blog content."""
|
|
|
|
def __init__(self):
|
|
logger.debug("Initialized BlogSEORecommendationApplier")
|
|
|
|
async def apply_recommendations(self, payload: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
|
|
"""Apply recommendations and return updated content."""
|
|
|
|
if not user_id:
|
|
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", {})
|
|
recommendations = payload.get("recommendations", [])
|
|
persona = payload.get("persona", {})
|
|
tone = payload.get("tone")
|
|
audience = payload.get("audience")
|
|
|
|
if not sections:
|
|
return {"success": False, "error": "No sections provided for recommendation application"}
|
|
|
|
if not recommendations:
|
|
logger.warning("apply_recommendations called without recommendations")
|
|
return {"success": True, "title": title, "sections": sections, "applied": []}
|
|
|
|
prompt = self._build_prompt(
|
|
title=title,
|
|
introduction=introduction,
|
|
sections=sections,
|
|
outline=outline,
|
|
research=research,
|
|
recommendations=recommendations,
|
|
persona=persona,
|
|
tone=tone,
|
|
audience=audience,
|
|
)
|
|
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"introduction": {"type": "string"},
|
|
"sections": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"heading": {"type": "string"},
|
|
"content": {"type": "string"},
|
|
"notes": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["id", "heading", "content"],
|
|
},
|
|
},
|
|
"applied_recommendations": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"category": {"type": "string"},
|
|
"summary": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"required": ["sections"],
|
|
}
|
|
|
|
logger.info("Applying SEO recommendations via llm_text_gen")
|
|
|
|
result = await asyncio.to_thread(
|
|
llm_text_gen,
|
|
prompt,
|
|
None,
|
|
schema,
|
|
user_id, # Pass user_id for subscription checking
|
|
max_tokens=8192,
|
|
)
|
|
|
|
if not result or result.get("error"):
|
|
error_msg = result.get("error", "Unknown error") if result else "No response from text generator"
|
|
logger.error(f"SEO recommendation application failed: {error_msg}")
|
|
return {"success": False, "error": error_msg}
|
|
|
|
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:
|
|
section_id = str(
|
|
updated.get("id")
|
|
or updated.get("section_id")
|
|
or updated.get("heading")
|
|
or ""
|
|
).strip()
|
|
|
|
if not section_id:
|
|
continue
|
|
|
|
heading = (
|
|
updated.get("heading")
|
|
or updated.get("title")
|
|
or section_id
|
|
)
|
|
|
|
content_text = updated.get("content", "")
|
|
if isinstance(content_text, list):
|
|
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
|
|
|
|
updated_map[section_id] = {
|
|
"id": section_id,
|
|
"heading": heading,
|
|
"content": str(content_text).strip(),
|
|
"notes": updated.get("notes", []),
|
|
}
|
|
|
|
if not updated_map and raw_sections:
|
|
logger.warning("Updated sections missing identifiers; falling back to positional mapping")
|
|
|
|
for index, original in enumerate(sections):
|
|
fallback_id = str(
|
|
original.get("id")
|
|
or original.get("section_id")
|
|
or f"section_{index + 1}"
|
|
).strip()
|
|
|
|
mapped = updated_map.get(fallback_id)
|
|
|
|
if not mapped and raw_sections:
|
|
# Fall back to positional match if identifier lookup failed
|
|
candidate = raw_sections[index] if index < len(raw_sections) else {}
|
|
heading = (
|
|
candidate.get("heading")
|
|
or candidate.get("title")
|
|
or original.get("heading")
|
|
or original.get("title")
|
|
or f"Section {index + 1}"
|
|
)
|
|
content_text = candidate.get("content") or original.get("content", "")
|
|
if isinstance(content_text, list):
|
|
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
|
|
mapped = {
|
|
"id": fallback_id,
|
|
"heading": heading,
|
|
"content": str(content_text).strip(),
|
|
"notes": candidate.get("notes", []),
|
|
}
|
|
|
|
if not mapped:
|
|
# Fallback to original content if nothing else available
|
|
mapped = {
|
|
"id": fallback_id,
|
|
"heading": original.get("heading") or original.get("title") or f"Section {index + 1}",
|
|
"content": str(original.get("content", "")).strip(),
|
|
"notes": original.get("notes", []),
|
|
}
|
|
|
|
normalized_sections.append(mapped)
|
|
|
|
applied = result.get("applied_recommendations", [])
|
|
|
|
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,
|
|
}
|
|
|
|
def _build_prompt(
|
|
self,
|
|
*,
|
|
title: str,
|
|
introduction: str,
|
|
sections: List[Dict[str, Any]],
|
|
outline: List[Dict[str, Any]],
|
|
research: Dict[str, Any],
|
|
recommendations: List[Dict[str, Any]],
|
|
persona: Dict[str, Any],
|
|
tone: str | None,
|
|
audience: str | None,
|
|
) -> str:
|
|
"""Construct prompt for applying recommendations."""
|
|
|
|
sections_str = []
|
|
for section in sections:
|
|
sections_str.append(
|
|
f"ID: {section.get('id', 'section')}, Heading: {section.get('heading', 'Untitled')}\n"
|
|
f"Current Content:\n{section.get('content', '')}\n"
|
|
)
|
|
|
|
outline_str = "\n".join(
|
|
[
|
|
f"- {item.get('heading', 'Section')} (Target words: {item.get('target_words', 'N/A')})"
|
|
for item in outline
|
|
]
|
|
)
|
|
|
|
research_summary = research.get("keyword_analysis", {}) if research else {}
|
|
primary_keywords = ", ".join(research_summary.get("primary", [])[:10]) or "None"
|
|
|
|
recommendations_str = []
|
|
for rec in recommendations:
|
|
recommendations_str.append(
|
|
f"Category: {rec.get('category', 'General')} | Priority: {rec.get('priority', 'Medium')}\n"
|
|
f"Recommendation: {rec.get('recommendation', '')}\n"
|
|
f"Impact: {rec.get('impact', '')}\n"
|
|
)
|
|
|
|
persona_str = (
|
|
f"Persona: {persona}\n"
|
|
if persona
|
|
else "Persona: (not provided)\n"
|
|
)
|
|
|
|
style_guidance = []
|
|
if tone:
|
|
style_guidance.append(f"Desired tone: {tone}")
|
|
if audience:
|
|
style_guidance.append(f"Target audience: {audience}")
|
|
style_str = "\n".join(style_guidance) if style_guidance else "Maintain current tone and audience alignment."
|
|
|
|
prompt = f"""
|
|
You are an expert SEO content strategist. Update the blog content to apply the actionable recommendations.
|
|
|
|
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:
|
|
{outline_str or 'No outline supplied'}
|
|
|
|
Existing Sections:
|
|
{''.join(sections_str)}
|
|
|
|
Actionable Recommendations to Apply:
|
|
{''.join(recommendations_str)}
|
|
|
|
{persona_str}
|
|
{style_str}
|
|
|
|
Instructions:
|
|
1. Carefully apply the recommendations while preserving factual accuracy and research alignment.
|
|
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
|
|
|
|
|
|
__all__ = ["BlogSEORecommendationApplier"]
|
|
|
|
|