chore: push all remaining changes
- Blog writer enhancements and bug fixes - Wix integration improvements - Frontend UI updates - GSC dashboard docs cleanup - Image studio assets - LinkedIn requirements file - Various dependency updates
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
|
||||
Applies actionable SEO recommendations to existing blog content using the
|
||||
provider-agnostic `llm_text_gen` dispatcher. Ensures GPT_PROVIDER parity.
|
||||
|
||||
Key design principles:
|
||||
- Make TARGETED edits, not full rewrites
|
||||
- Preserve existing content structure and factual claims
|
||||
- Only modify sections that have applicable recommendations
|
||||
- Never fabricate statistics, case studies, or citations
|
||||
- Ground changes in research sources when available
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,7 +22,7 @@ logger = get_service_logger("blog_seo_recommendation_applier")
|
||||
|
||||
|
||||
class BlogSEORecommendationApplier:
|
||||
"""Apply actionable SEO recommendations to blog content."""
|
||||
"""Apply actionable SEO recommendations to blog content with targeted edits."""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initialized BlogSEORecommendationApplier")
|
||||
@@ -35,6 +42,7 @@ class BlogSEORecommendationApplier:
|
||||
persona = payload.get("persona", {})
|
||||
tone = payload.get("tone")
|
||||
audience = payload.get("audience")
|
||||
competitive_advantage = payload.get("competitive_advantage", "")
|
||||
|
||||
if not sections:
|
||||
return {"success": False, "error": "No sections provided for recommendation application"}
|
||||
@@ -43,16 +51,21 @@ class BlogSEORecommendationApplier:
|
||||
logger.warning("apply_recommendations called without recommendations")
|
||||
return {"success": True, "title": title, "sections": sections, "applied": []}
|
||||
|
||||
# Determine which sections actually need changes based on recommendations
|
||||
sections_to_edit = self._identify_affected_sections(sections, recommendations)
|
||||
|
||||
prompt = self._build_prompt(
|
||||
title=title,
|
||||
introduction=introduction,
|
||||
sections=sections,
|
||||
sections_to_edit=sections_to_edit,
|
||||
outline=outline,
|
||||
research=research,
|
||||
recommendations=recommendations,
|
||||
persona=persona,
|
||||
tone=tone,
|
||||
audience=audience,
|
||||
competitive_advantage=competitive_advantage,
|
||||
)
|
||||
|
||||
schema = {
|
||||
@@ -87,14 +100,14 @@ class BlogSEORecommendationApplier:
|
||||
"required": ["sections"],
|
||||
}
|
||||
|
||||
logger.info("Applying SEO recommendations via llm_text_gen")
|
||||
logger.info("Applying SEO recommendations via llm_text_gen (targeted edit mode)")
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
llm_text_gen,
|
||||
prompt,
|
||||
None,
|
||||
schema,
|
||||
user_id, # Pass user_id for subscription checking
|
||||
user_id,
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
@@ -106,14 +119,12 @@ class BlogSEORecommendationApplier:
|
||||
raw_sections = result.get("sections", []) or []
|
||||
normalized_sections: List[Dict[str, Any]] = []
|
||||
|
||||
# Warn if LLM returned different number of sections (may miss intro/conclusion added as new sections)
|
||||
if len(raw_sections) != len(sections):
|
||||
logger.warning(
|
||||
f"LLM returned {len(raw_sections)} sections but {len(sections)} were sent. "
|
||||
"Extra sections will be ignored; missing sections fall back to original content."
|
||||
)
|
||||
|
||||
# Build lookup table from updated sections using their identifiers
|
||||
updated_map: Dict[str, Dict[str, Any]] = {}
|
||||
for updated in raw_sections:
|
||||
section_id = str(
|
||||
@@ -156,7 +167,6 @@ class BlogSEORecommendationApplier:
|
||||
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")
|
||||
@@ -176,7 +186,6 @@ class BlogSEORecommendationApplier:
|
||||
}
|
||||
|
||||
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}",
|
||||
@@ -190,12 +199,11 @@ class BlogSEORecommendationApplier:
|
||||
|
||||
logger.info("SEO recommendations applied successfully")
|
||||
|
||||
# Extract updated introduction from LLM response if available
|
||||
updated_introduction = result.get("introduction") or ""
|
||||
if updated_introduction and updated_introduction != introduction:
|
||||
logger.info(f"Introduction updated: {len(updated_introduction)} chars")
|
||||
elif not updated_introduction:
|
||||
updated_introduction = introduction # fall back to original
|
||||
updated_introduction = introduction
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -205,37 +213,133 @@ class BlogSEORecommendationApplier:
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
def _identify_affected_sections(self, sections: List[Dict[str, Any]], recommendations: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Identify which section IDs are likely affected by the recommendations.
|
||||
|
||||
Maps recommendation categories to section headings for targeted editing.
|
||||
Returns a list of section IDs that should be edited.
|
||||
"""
|
||||
affected_ids = set()
|
||||
|
||||
for rec in recommendations:
|
||||
category = (rec.get("category") or "").lower()
|
||||
rec_text = (rec.get("recommendation") or "").lower()
|
||||
|
||||
# Structure recommendations affect first/last sections or all sections
|
||||
if category == "structure":
|
||||
if sections:
|
||||
affected_ids.add(str(sections[0].get("id", "section_1")))
|
||||
affected_ids.add(str(sections[-1].get("id", f"section_{len(sections)}")))
|
||||
# "Add more sections" or "too many sections" affects all
|
||||
if "more section" in rec_text or "combine" in rec_text or "flow" in rec_text:
|
||||
for s in sections:
|
||||
affected_ids.add(str(s.get("id", "")))
|
||||
continue
|
||||
|
||||
# Keyword recommendations affect all sections (keywords should be spread)
|
||||
if category == "keywords":
|
||||
for s in sections:
|
||||
affected_ids.add(str(s.get("id", "")))
|
||||
continue
|
||||
|
||||
# Readability affects all sections
|
||||
if category == "readability":
|
||||
for s in sections:
|
||||
affected_ids.add(str(s.get("id", "")))
|
||||
continue
|
||||
|
||||
# Content quality — try to match recommendation to specific section headings
|
||||
if category in ("content quality", "content", "seo"):
|
||||
heading_keywords = {
|
||||
s.get("heading", "").lower(): str(s.get("id", ""))
|
||||
for s in sections
|
||||
}
|
||||
matched = False
|
||||
for heading_lower, section_id in heading_keywords.items():
|
||||
rec_words = rec_text.split()
|
||||
if any(word in heading_lower for word in rec_words if len(word) > 3):
|
||||
affected_ids.add(section_id)
|
||||
matched = True
|
||||
if not matched:
|
||||
# Affect first and last sections (intro/conclusion) as common targets
|
||||
if sections:
|
||||
affected_ids.add(str(sections[0].get("id", "section_1")))
|
||||
affected_ids.add(str(sections[-1].get("id", f"section_{len(sections)}")))
|
||||
|
||||
# Filter out empty IDs and return
|
||||
return [sid for sid in affected_ids if sid]
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
introduction: str,
|
||||
sections: List[Dict[str, Any]],
|
||||
sections_to_edit: List[str],
|
||||
outline: List[Dict[str, Any]],
|
||||
research: Dict[str, Any],
|
||||
recommendations: List[Dict[str, Any]],
|
||||
persona: Dict[str, Any],
|
||||
tone: str | None,
|
||||
audience: str | None,
|
||||
competitive_advantage: str = "",
|
||||
) -> str:
|
||||
"""Construct prompt for applying recommendations."""
|
||||
"""Construct prompt for applying targeted recommendations."""
|
||||
|
||||
sections_str = []
|
||||
# Build research context block
|
||||
research_block = ""
|
||||
keyword_analysis = research.get("keyword_analysis", {}) if research else {}
|
||||
primary_keywords = ", ".join(keyword_analysis.get("primary", [])[:8]) or "None"
|
||||
competitor_analysis = research.get("competitor_analysis", {}) if research else {}
|
||||
search_queries = research.get("search_queries", []) if research else []
|
||||
suggested_angles = research.get("suggested_angles", []) if research else []
|
||||
content_gaps = competitor_analysis.get("content_gaps", []) if competitor_analysis else []
|
||||
competitive_advantages = competitor_analysis.get("competitive_advantages", []) if competitor_analysis else []
|
||||
|
||||
research_block += f"\nPRIMARY KEYWORDS: {primary_keywords}"
|
||||
if content_gaps:
|
||||
research_block += f"\nCONTENT GAPS (address these in your edits): {', '.join(content_gaps[:5])}"
|
||||
if competitive_advantages:
|
||||
research_block += f"\nKEY DIFFERENTIATORS (emphasize these): {', '.join(competitive_advantages[:3])}"
|
||||
if competitive_advantage:
|
||||
research_block += f"\nPRIMARY ADVANTAGE: {competitive_advantage}"
|
||||
if search_queries:
|
||||
research_block += f"\nTARGET SEARCH QUERIES: {', '.join(search_queries[:5])}"
|
||||
if suggested_angles:
|
||||
research_block += f"\nCONTENT ANGLES: {', '.join(suggested_angles[:3])}"
|
||||
|
||||
# Build per-section content with edit markers
|
||||
sections_content = []
|
||||
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"
|
||||
)
|
||||
section_id = str(section.get("id", "section"))
|
||||
heading = section.get("heading", "Untitled")
|
||||
content = section.get("content", "")
|
||||
needs_edit = section_id in sections_to_edit
|
||||
|
||||
section_text = f"--- SECTION (ID: {section_id}, Heading: \"{heading}\")"
|
||||
if needs_edit:
|
||||
section_text += " [NEEDS EDITS based on recommendations]"
|
||||
else:
|
||||
section_text += " [KEEP AS-IS - no changes needed]"
|
||||
section_text += f" ---\n{content}\n"
|
||||
sections_content.append(section_text)
|
||||
|
||||
sections_str = "\n\n".join(sections_content)
|
||||
|
||||
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"
|
||||
# Build outline with subheadings and key points
|
||||
outline_parts = []
|
||||
for item in outline:
|
||||
heading = item.get("heading", "Section")
|
||||
target_words = item.get("target_words", "N/A")
|
||||
subheadings = item.get("subheadings", [])
|
||||
key_points = item.get("key_points", [])
|
||||
line = f"- {heading} (Target: {target_words} words)"
|
||||
if subheadings:
|
||||
line += f" | Subheadings: {', '.join(subheadings[:4])}"
|
||||
if key_points:
|
||||
line += f" | Key points: {', '.join(key_points[:4])}"
|
||||
outline_parts.append(line)
|
||||
outline_str = "\n".join(outline_parts) if outline_parts else "No outline supplied"
|
||||
|
||||
recommendations_str = []
|
||||
for rec in recommendations:
|
||||
@@ -248,7 +352,7 @@ class BlogSEORecommendationApplier:
|
||||
persona_str = (
|
||||
f"Persona: {persona}\n"
|
||||
if persona
|
||||
else "Persona: (not provided)\n"
|
||||
else ""
|
||||
)
|
||||
|
||||
style_guidance = []
|
||||
@@ -258,44 +362,47 @@ class BlogSEORecommendationApplier:
|
||||
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.
|
||||
intro_text = introduction if introduction else "(No introduction currently — write one ONLY if a recommendation specifically asks for it)"
|
||||
|
||||
Current Title: {title}
|
||||
prompt = f"""You are a careful SEO content editor making TARGETED edits to an existing blog post. Your job is to apply specific SEO recommendations with PRECISION — not to rewrite the entire post.
|
||||
|
||||
Current Introduction:
|
||||
{introduction if introduction else '(No introduction exists — write a compelling one if the recommendations require it)'}
|
||||
CRITICAL RULES — YOU MUST FOLLOW THESE:
|
||||
1. PRESERVE existing content. Only make MINIMAL, targeted changes to address specific recommendations. Do NOT rewrite sections that are working well.
|
||||
2. NEVER fabricate statistics, case studies, expert quotes, research data, or specific numbers unless they are explicitly stated in the research context below.
|
||||
3. NEVER add content that contradicts or goes beyond what the research sources support.
|
||||
4. KEEP the same emotional tone and writing style as the original content.
|
||||
5. Return EXACTLY the same number of sections with EXACTLY the same IDs. Do NOT add, remove, or rename sections.
|
||||
6. For sections marked [KEEP AS-IS], return the content UNCHANGED — copy it verbatim.
|
||||
7. For sections marked [NEEDS EDITS], make ONLY the specific changes needed to address the applicable recommendations.
|
||||
8. Do NOT add introductions, conclusions, or case studies unless a recommendation EXPLICITLY asks for one.
|
||||
|
||||
Primary Keywords (for context): {primary_keywords}
|
||||
{research_block}
|
||||
|
||||
Outline Overview:
|
||||
{outline_str or 'No outline supplied'}
|
||||
PLANNED OUTLINE STRUCTURE:
|
||||
{outline_str}
|
||||
|
||||
Existing Sections:
|
||||
{''.join(sections_str)}
|
||||
CURRENT TITLE: {title}
|
||||
|
||||
Actionable Recommendations to Apply:
|
||||
CURRENT INTRODUCTION:
|
||||
{intro_text}
|
||||
|
||||
CURRENT SECTIONS:
|
||||
{sections_str}
|
||||
|
||||
RECOMMENDATIONS TO APPLY:
|
||||
{''.join(recommendations_str)}
|
||||
{persona_str}{style_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.
|
||||
INSTRUCTIONS:
|
||||
- For sections marked [KEEP AS-IS]: Copy the content EXACTLY as provided. Do not change a single word.
|
||||
- For sections marked [NEEDS EDITS]: Make the MINIMUM changes needed to address the recommendations. If a recommendation says "add transition words", add 2-3 transitions — do not rewrite the paragraph. If it says "use more varied vocabulary", replace 2-3 repetitive words — do not rewrite the section.
|
||||
- If a recommendation asks for an introduction and none exists, write a brief 2-3 sentence introduction that naturally leads into the first section. Do NOT fabricate hooks or statistics.
|
||||
- If a recommendation asks for a conclusion, append 2-3 sentences summarizing key takeaways to the LAST section. Do NOT fabricate conclusions that don't follow from the actual content.
|
||||
- Return ALL sections, including the ones you did NOT change.
|
||||
- Provide a summary of which recommendations you addressed and what specific changes you made.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
__all__ = ["BlogSEORecommendationApplier"]
|
||||
|
||||
|
||||
__all__ = ["BlogSEORecommendationApplier"]
|
||||
Reference in New Issue
Block a user