diff --git a/CHANGELOG.md b/CHANGELOG.md index 209657f..83b9de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.8] - 2025-11-07 +### Added +- Completely redesign of how content gets generated. + ## [0.0.8] - 2025-11-07 ### Added - Table of contents to Project Settings Page (only shows h2 headings) diff --git a/core/agents.py b/core/agents.py deleted file mode 100644 index e7f0030..0000000 --- a/core/agents.py +++ /dev/null @@ -1,233 +0,0 @@ -from django.conf import settings -from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -from core.agent_system_prompts import ( - add_language_specification, - add_project_details, - add_project_pages, - add_target_keywords, - add_title_details, - add_webpage_content, - markdown_lists, -) -from core.choices import AIModel, get_default_ai_model -from core.schemas import ( - BlogPostGenerationContext, - CompetitorVsPostContext, - CompetitorVsTitleContext, - ProjectDetails, - ProjectPageDetails, - TitleSuggestion, - WebPageContent, -) - -######################################################## - -content_editor_agent = Agent( - get_default_ai_model(), - output_type=str, - deps_type=BlogPostGenerationContext, - system_prompt=""" - You are an expert content editor. - - Your task is to edit the blog post content based on the requested changes. - """, - retries=2, - model_settings={"temperature": 0.3}, -) - - -@content_editor_agent.system_prompt -def only_return_the_edited_content() -> str: - return """ - IMPORTANT: Only return the edited content, no other text. - """ - - -content_editor_agent.system_prompt(add_project_details) -content_editor_agent.system_prompt(add_project_pages) -content_editor_agent.system_prompt(add_title_details) -content_editor_agent.system_prompt(add_language_specification) -content_editor_agent.system_prompt(add_target_keywords) - - -######################################################## - -analyze_project_agent = Agent( - get_default_ai_model(), - output_type=ProjectDetails, - deps_type=WebPageContent, - system_prompt=( - "You are an expert content analyzer. Based on the content provided, " - "extract and infer the requested information. Make reasonable inferences based " - "on available content, context, and industry knowledge." - ), - retries=2, -) -analyze_project_agent.system_prompt(add_webpage_content) - - -######################################################## - -summarize_page_agent = Agent( - get_default_ai_model(), - output_type=ProjectPageDetails, - deps_type=WebPageContent, - system_prompt=( - "You are an expert content summarizer. Based on the web page content provided, " - "create a concise 2-3 sentence summary that captures the main purpose and key " - "information of the page. Focus on what the page is about and its main value proposition." - ), - retries=2, - model_settings={"temperature": 0.5}, -) -summarize_page_agent.system_prompt(add_webpage_content) - - -######################################################## - -competitor_vs_title_agent = Agent( - OpenAIChatModel( - AIModel.PERPLEXITY_SONAR, - provider=OpenAIProvider( - base_url="https://api.perplexity.ai", - api_key=settings.PERPLEXITY_API_KEY, - ), - ), - output_type=TitleSuggestion, - deps_type=CompetitorVsTitleContext, - system_prompt=""" - You are an expert content strategist specializing in comparison content. - - Your task is to generate a single compelling blog post title that compares - the user's project with a specific competitor. The title should: - - 1. Follow the format: "[Project] vs. [Competitor]: [Compelling Angle]" - 2. Highlight a clear, specific angle for comparison (pricing, features, use cases, etc.) - 3. Be SEO-optimized and search-friendly - 4. Create intrigue and encourage clicks - 5. Be factual and professional (not clickbait) - - Use the latest information available through web search to understand both products - and create an informed, relevant comparison angle. - """, - retries=2, - model_settings={"temperature": 0.8}, -) - -competitor_vs_title_agent.system_prompt(markdown_lists) - - -@competitor_vs_title_agent.system_prompt -def add_competitor_vs_context(ctx): - context: CompetitorVsTitleContext = ctx.deps - project = context.project_details - competitor = context.competitor_details - - return f""" - Project Details: - - Name: {project.name} - - Type: {project.type} - - Summary: {project.summary} - - Key Features: {project.key_features} - - Target Audience: {project.target_audience_summary} - - Competitor Details: - - Name: {competitor.name} - - URL: {competitor.url} - - Description: {competitor.description} - - IMPORTANT: Use web search to research both products and generate - an informed, specific comparison angle. The title should be based on - real, up-to-date information about both products. - - Generate only ONE title suggestion with its metadata. - """ - - -competitor_vs_title_agent.system_prompt(add_language_specification) - - -######################################################## - -competitor_vs_blog_post_agent = Agent( - OpenAIChatModel( - AIModel.PERPLEXITY_SONAR, - provider=OpenAIProvider( - base_url="https://api.perplexity.ai", - api_key=settings.PERPLEXITY_API_KEY, - ), - ), - output_type=str, - deps_type=CompetitorVsPostContext, - system_prompt=""" - You are an expert content writer specializing in product comparisons. - - Create a comprehensive, comparison blog post between two products. - The post should: - - 1. Be well-researched using current information from the web - 2. Include an introduction explaining what both products are - 3. Compare key features, pricing, use cases, pros/cons - 4. Have a slight preference toward the user's project (be subtle) - 5. Include a conclusion helping readers make a decision - 6. Be SEO-optimized with proper headings and structure - 7. Be written in markdown format - 8. Be at least 2000 words - 9. Return ONLY the markdown content, no JSON or structured output - - Important formatting rules: - - Use ## for main headings (not #) - - Use ### for subheadings - - Include bullet points for lists - - Add a comparison table if relevant - - Include internal links where appropriate - """, - retries=2, - model_settings={"max_tokens": 8000, "temperature": 0.7}, -) - -competitor_vs_blog_post_agent.system_prompt(markdown_lists) -competitor_vs_blog_post_agent.system_prompt(add_project_pages) - - -@competitor_vs_blog_post_agent.system_prompt -def output_format() -> str: - return """ - IMPORTANT: Return only the text. Don't surround the text with ```markdown or ```. - """ - - -@competitor_vs_blog_post_agent.system_prompt -def links_insertion() -> str: - return """ - Instead of leaving reference to links in the text (like this 'sample text[1]'), insert the links into the text in markdown format. - For example, if the text is 'sample text[1]', the link should be inserted like this: '[sample text](https://www.example.com)'. - """ # noqa: E501 - - -@competitor_vs_blog_post_agent.system_prompt -def add_competitor_vs_post_context(ctx) -> str: - context: CompetitorVsPostContext = ctx.deps - - return f""" - Product 1 (Our Product): {context.project_name} - URL: {context.project_url} - Description: {context.project_summary} - - Product 2 (Competitor): {context.competitor_name} - URL: {context.competitor_url} - Description: {context.competitor_description} - - Blog Post Title: "{context.title}" - - Language: {context.language} - - Use web search to gather the latest information about both products. - Research their features, pricing, user reviews, and positioning. - Create an informative comparison that helps readers make an informed decision. - - Have a slight preference toward {context.project_name} but remain fair and unbiased. - """ diff --git a/core/agents/analyze_project_agent.py b/core/agents/analyze_project_agent.py new file mode 100644 index 0000000..df4ad20 --- /dev/null +++ b/core/agents/analyze_project_agent.py @@ -0,0 +1,23 @@ +from pydantic_ai import Agent + +from core.agents.system_prompts import ( + add_webpage_content, +) +from core.choices import get_default_ai_model +from core.schemas import ( + ProjectDetails, + WebPageContent, +) + +agent = Agent( + get_default_ai_model(), + output_type=ProjectDetails, + deps_type=WebPageContent, + system_prompt=( + "You are an expert content analyzer. Based on the content provided, " + "extract and infer the requested information. Make reasonable inferences based " + "on available content, context, and industry knowledge." + ), + retries=2, +) +agent.system_prompt(add_webpage_content) diff --git a/core/agents/blog_structure_agent.py b/core/agents/blog_structure_agent.py new file mode 100644 index 0000000..d3e1397 --- /dev/null +++ b/core/agents/blog_structure_agent.py @@ -0,0 +1,48 @@ +from pydantic_ai import Agent + +from core.agents.system_prompts import ( + add_language_specification, + add_project_details, + add_project_pages, + add_target_keywords, + add_title_details, +) +from core.choices import get_default_ai_model +from core.schemas import BlogPostGenerationContext, BlogPostStructure + +agent = Agent( + get_default_ai_model(), + output_type=BlogPostStructure, + deps_type=BlogPostGenerationContext, + system_prompt=""" + You are an expert content strategist and SEO specialist. + + Your task is to create a comprehensive, well-structured outline for a blog post. + Think deeply about: + + 1. **Logical Flow**: How should information be presented for maximum clarity and impact? + 2. **SEO Optimization**: What headings and structure will rank well in search engines? + 3. **User Intent**: What questions does the reader have, and in what order should they be answered? + 4. **Comprehensiveness**: What topics must be covered for this to be a complete resource? + 5. **Readability**: How can we break down complex topics into digestible sections? + + Consider the project details, title suggestion, and available project pages when creating the structure. + The structure should be detailed enough that a writer can follow it to create excellent content. + + Important guidelines: + - Use H2 (level 2) for main sections + - Use H3 (level 3) for subsections within main sections + - Aim for 5-8 main sections (H2) for a comprehensive post + - Each section should have 3-5 key points to cover + - Target 2000-3000 total words for the post + - Include specific guidance for introduction and conclusion + """, # noqa: E501 + retries=2, + model_settings={"temperature": 0.7}, +) + +agent.system_prompt(add_project_details) +agent.system_prompt(add_project_pages) +agent.system_prompt(add_title_details) +agent.system_prompt(add_language_specification) +agent.system_prompt(add_target_keywords) diff --git a/core/agents/competitor_finder_agent.py b/core/agents/competitor_finder_agent.py new file mode 100644 index 0000000..fce5826 --- /dev/null +++ b/core/agents/competitor_finder_agent.py @@ -0,0 +1,86 @@ +from django.conf import settings +from pydantic_ai import Agent, RunContext +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from core.choices import AIModel +from core.schemas import ProjectDetails + + +def get_project_details_prompt(ctx: RunContext[ProjectDetails]) -> str: + project = ctx.deps + return f"""I'm working on a project which has the following attributes: + Name: + {project.name} + + Summary: + {project.summary} + + Key Features: + {project.key_features} + + Target Audience: + {project.target_audience_summary} + + Pain Points Addressed: + {project.pain_points} + + Language: {project.language} + """ + + +def required_data_prompt() -> str: + return "Make sure that each competitor has a name, url, and description." + + +def get_number_of_competitors_prompt(ctx: RunContext[ProjectDetails]) -> str: + is_on_free_plan = ctx.deps.is_on_free_plan + if is_on_free_plan: + return "Give me a list of exactly 3 competitors." + return "Give me a list of at least 20 competitors." + + +def get_language_specification_prompt(ctx: RunContext[ProjectDetails]) -> str: + project = ctx.deps + return f""" + IMPORTANT: Be mindful that competitors are likely to speak in + {project.language} language. + """ + + +def get_location_specification_prompt(ctx: RunContext[ProjectDetails]) -> str: + project = ctx.deps + if project.location != "Global": + return f""" + IMPORTANT: Only return competitors whose target audience is in + {project.location}. + """ + else: + return """ + IMPORTANT: Return competitors from all over the world. + """ + + +model = OpenAIChatModel( + AIModel.PERPLEXITY_SONAR, + provider=OpenAIProvider( + base_url="https://api.perplexity.ai", + api_key=settings.PERPLEXITY_API_KEY, + ), +) + +agent = Agent( + model, + deps_type=ProjectDetails, + output_type=str, + system_prompt=""" + You are a helpful assistant that helps me find competitors for my project. + """, + retries=2, +) + +agent.system_prompt(get_project_details_prompt) +agent.system_prompt(required_data_prompt) +agent.system_prompt(get_number_of_competitors_prompt) +agent.system_prompt(get_language_specification_prompt) +agent.system_prompt(get_location_specification_prompt) diff --git a/core/agents/competitor_vs_blog_post_agent.py b/core/agents/competitor_vs_blog_post_agent.py new file mode 100644 index 0000000..7fc44fe --- /dev/null +++ b/core/agents/competitor_vs_blog_post_agent.py @@ -0,0 +1,87 @@ +from django.conf import settings +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from core.agents.system_prompts import ( + add_project_pages, + inline_link_formatting, + markdown_lists, +) +from core.choices import AIModel +from core.schemas import CompetitorVsPostContext + +agent = Agent( + OpenAIChatModel( + AIModel.PERPLEXITY_SONAR, + provider=OpenAIProvider( + base_url="https://api.perplexity.ai", + api_key=settings.PERPLEXITY_API_KEY, + ), + ), + output_type=str, + deps_type=CompetitorVsPostContext, + system_prompt=""" + You are an expert content writer specializing in product comparisons. + + Create a comprehensive, comparison blog post between two products. + The post should: + + 1. Be well-researched using current information from the web + 2. Include an introduction explaining what both products are + 3. Compare key features, pricing, use cases, pros/cons + 4. Have a slight preference toward the user's project (be subtle) + 5. Include a conclusion helping readers make a decision + 6. Be SEO-optimized with proper headings and structure + 7. Be written in markdown format + 8. Be at least 2000 words + 9. Return ONLY the markdown content, no JSON or structured output + + Important formatting rules: + - Use ## for main headings (not #) + - Use ### for subheadings + - Include bullet points for lists + - Add a comparison table if relevant + - Include internal links where appropriate + """, + retries=2, + model_settings={"max_tokens": 8000, "temperature": 0.7}, +) + +agent.system_prompt(markdown_lists) +agent.system_prompt(add_project_pages) + + +@agent.system_prompt +def output_format() -> str: + return """ + IMPORTANT: Return only the text. Don't surround the text with ```markdown or ```. + """ + + +agent.system_prompt(inline_link_formatting) + + +@agent.system_prompt +def add_competitor_vs_post_context(ctx) -> str: + context: CompetitorVsPostContext = ctx.deps + + return f""" + Product 1 (Our Product): {context.project_name} + URL: {context.project_url} + Description: {context.project_summary} + + Product 2 (Competitor): {context.competitor_name} + URL: {context.competitor_url} + Description: {context.competitor_description} + + Blog Post Title: "{context.title}" + + Language: {context.language} + + Use web search to gather the latest information about both products. + Research their features, pricing, user reviews, and positioning. + Create an informative comparison that helps readers make an informed decision. + + Have a slight preference toward {context.project_name} but remain fair and unbiased. + """ diff --git a/core/agents/content_editor_agent.py b/core/agents/content_editor_agent.py new file mode 100644 index 0000000..7d8ac39 --- /dev/null +++ b/core/agents/content_editor_agent.py @@ -0,0 +1,40 @@ +from pydantic_ai import Agent + +from core.agents.system_prompts import ( + add_language_specification, + add_project_details, + add_project_pages, + add_target_keywords, + add_title_details, + inline_link_formatting, +) +from core.choices import get_default_ai_model +from core.schemas import BlogPostGenerationContext + +agent = Agent( + get_default_ai_model(), + output_type=str, + deps_type=BlogPostGenerationContext, + system_prompt=""" + You are an expert content editor. + + Your task is to edit the blog post content based on the requested changes. + """, + retries=2, + model_settings={"temperature": 0.3}, +) + + +@agent.system_prompt +def only_return_the_edited_content() -> str: + return """ + IMPORTANT: Only return the edited content, no other text. + """ + + +agent.system_prompt(add_project_details) +agent.system_prompt(add_project_pages) +agent.system_prompt(add_title_details) +agent.system_prompt(add_language_specification) +agent.system_prompt(add_target_keywords) +agent.system_prompt(inline_link_formatting) diff --git a/core/agents/content_validation_agent.py b/core/agents/content_validation_agent.py new file mode 100644 index 0000000..0188cd0 --- /dev/null +++ b/core/agents/content_validation_agent.py @@ -0,0 +1,51 @@ +from pydantic_ai import Agent + +from core.choices import get_default_ai_model +from core.schemas import ContentValidationContext, ContentValidationResult + +agent = Agent( + get_default_ai_model(), + output_type=ContentValidationResult, + deps_type=ContentValidationContext, + system_prompt=""" + You are an expert content quality validator for blog posts. + + Your task is to review blog post content and determine if it is complete and ready for publication. + + A valid blog post should: + - Be substantial in length (at least a few paragraphs) + - Have a clear beginning, middle, and end + - End with a proper conclusion (not cut off mid-sentence or mid-thought) + - Be coherent and well-structured + - Not contain obvious placeholders or incomplete sections + - **Match the title and description semantically** - the content must actually address the topics, themes, and promises made in the title and description + - Cover the target keywords naturally if provided + + **CRITICAL: Semantic Alignment Check** + You must verify that the content actually delivers on what the title and description promise. + For example: + - If the title is "10 Best Practices for Python Testing", the content must discuss Python testing best practices + - If the description mentions "comparing frameworks", the content must include framework comparisons + - If the title focuses on a specific topic, the content cannot be about an entirely different subject + + Common mismatches to catch: + - Content generated for the wrong topic entirely + - Title promises specific information that the content doesn't deliver + - Content that is generic when the title promises specific details + - Target keywords that are completely absent from the content + + Return is_valid=True if the content meets these quality standards and is ready for publication. + Return is_valid=False if the content appears incomplete, cut off, has significant quality issues, or doesn't match the title/description. + + When is_valid=False, provide a list of specific validation_issues that describe: + - What specific problems were found + - What is missing or incomplete + - What needs to be fixed + - **If there's a semantic mismatch between title/description and content** + + Be reasonable in your assessment - minor imperfections are acceptable. + Focus on whether the content is genuinely complete, publication-ready, and aligned with its title. + """, # noqa: E501 + retries=1, + model_settings={"temperature": 0.2}, +) diff --git a/core/agents/fix_validation_issue_agent.py b/core/agents/fix_validation_issue_agent.py new file mode 100644 index 0000000..5b5007e --- /dev/null +++ b/core/agents/fix_validation_issue_agent.py @@ -0,0 +1,42 @@ +from pydantic_ai import Agent, RunContext + +from core.choices import get_default_ai_model +from core.schemas import ContentFixContext + +agent = Agent( + get_default_ai_model(), + output_type=str, + deps_type=ContentFixContext, + system_prompt=""" + You are an expert content editor specializing in fixing incomplete or problematic blog posts. + + Your task is to fix the provided blog post content based on the specific validation issues identified. + + Guidelines: + - Address each validation issue thoroughly + - Maintain the original style, tone, and structure of the content + - Keep all existing good content intact + - Only fix what is broken or incomplete + - If content is cut off, complete it naturally + - If sections are missing, add them appropriately + - Ensure the final content has a proper conclusion + - Preserve markdown formatting + - Do not add placeholders or temporary content + + Return ONLY the fixed markdown content, without any additional commentary or explanations. + """, # noqa: E501 + retries=2, + model_settings={"temperature": 0.5}, +) + + +@agent.system_prompt +def add_content_and_issues(ctx: RunContext[ContentFixContext]) -> str: + issues_text = "\n".join(f"- {issue}" for issue in ctx.deps.validation_issues) + return f""" + Original Content: + {ctx.deps.content} + + Validation Issues to Fix: + {issues_text} + """ diff --git a/core/agents/internal_links_agent.py b/core/agents/internal_links_agent.py new file mode 100644 index 0000000..0515c94 --- /dev/null +++ b/core/agents/internal_links_agent.py @@ -0,0 +1,75 @@ +from pydantic_ai import Agent + +from core.agents.system_prompts import inline_link_formatting +from core.choices import get_default_ai_model +from core.schemas import InternalLinkContext + +agent = Agent( + get_default_ai_model(), + output_type=str, + deps_type=InternalLinkContext, + system_prompt=""" + You are an expert at strategically inserting internal links into content. + + Your task is to identify relevant places in the blog post content where internal links + to the project's pages would add value for readers and improve SEO. + + Guidelines: + - Only insert links where they are contextually relevant and add value + - Use natural anchor text that fits the flow of the content + - For "always_use" pages, you MUST find appropriate places to link them + - For optional pages, only link if they truly enhance the content + - Avoid over-linking (max 1 link per 200 words as a general rule) + - Vary your anchor text - don't use the same text repeatedly + - Link early in the content when possible, but prioritize natural placement + + Return the content with internal links inserted in markdown format: [anchor text](url) + + IMPORTANT: Return ONLY the modified content, no additional commentary or explanation. + """, + retries=2, + model_settings={"temperature": 0.3}, +) + + +agent.system_prompt(inline_link_formatting) + + +@agent.system_prompt +def add_internal_link_context(ctx) -> str: + context: InternalLinkContext = ctx.deps + + always_use_pages = [page for page in context.available_pages if page.always_use] + optional_pages = [page for page in context.available_pages if not page.always_use] + + instruction = f""" + Current Content: + {context.content} + + """ + + if always_use_pages: + instruction += """ + REQUIRED PAGES TO LINK (Must be included): + """ + for page in always_use_pages: + instruction += f""" + - Title: {page.title} + - URL: {page.url} + - Description: {page.description} + + """ + + if optional_pages: + instruction += """ + OPTIONAL PAGES (Link only if contextually relevant): + """ + for page in optional_pages: + instruction += f""" + - Title: {page.title} + - URL: {page.url} + - Description: {page.description} + + """ + + return instruction diff --git a/core/agents/seo_content_generator_agent.py b/core/agents/seo_content_generator_agent.py new file mode 100644 index 0000000..3ed8f60 --- /dev/null +++ b/core/agents/seo_content_generator_agent.py @@ -0,0 +1,115 @@ +import json + +from pydantic_ai import Agent, RunContext + +from core.agents.system_prompts import ( + add_language_specification, + add_project_details, + add_target_keywords, + add_title_details, + add_todays_date, + filler_content, + inline_link_formatting, + markdown_lists, + post_structure, + valid_markdown_format, +) +from core.choices import get_default_ai_model +from core.schemas import BlogPostGenerationContext, GeneratedBlogPostSchema + +seo_content_prompt = """ +You are an expert SEO content writer with deep knowledge of search engine algorithms and user engagement metrics. Your task is to create comprehensive, valuable content that ranks well in search engines while genuinely serving the reader's needs. + +I'll provide a blog post title, and I need you to generate high-quality, SEO-optimized content following these guidelines: + +1. CONTENT STRUCTURE: + - Begin with a compelling introduction that includes the primary keyword and clearly states what the reader will learn + - Use H2 and H3 headings to organize content logically, incorporating relevant keywords naturally + - Include a clear conclusion that summarizes key points and provides next steps or a call-to-action + - Aim for comprehensive coverage with appropriate length (typically 1,200-2,000 words for most topics) + +2. SEO OPTIMIZATION: + - Naturally incorporate the primary keyword 3-5 times throughout the content (including once in the first 100 words) + - Use related secondary keywords and semantic variations to demonstrate topical authority + - Optimize meta description (150-160 characters) that includes the primary keyword and encourages clicks + - Create a URL slug that is concise and includes the primary keyword + +3. CONTENT QUALITY: + - Provide unique insights, not just information that can be found everywhere + - Include specific examples, case studies, or data points to support claims + - Answer the most important questions users have about this topic + - Address potential objections or concerns readers might have + +4. READABILITY: + - Write in a conversational, accessible tone appropriate for the target audience + - Use short paragraphs (2-3 sentences maximum) + - Include bulleted or numbered lists where appropriate + - Vary sentence structure to maintain reader interest + - Aim for a reading level appropriate to your audience (typically 7th-9th grade level) + +5. ENGAGEMENT ELEMENTS: + - Include 2-3 suggested places for relevant images, charts, or infographics with descriptive alt text + - Add internal linking opportunities to 3-5 related content pieces on your site + - Suggest 2-3 external authoritative sources to link to for supporting evidence + - Include questions throughout that prompt reader reflection + +6. E-E-A-T SIGNALS: + - Demonstrate Expertise through depth of information + - Show Experience by including practical applications or real-world examples + - Establish Authoritativeness by referencing industry standards or best practices + - Build Trustworthiness by presenting balanced information and citing sources + +7. USER INTENT SATISFACTION: + - Identify whether the search intent is informational, navigational, commercial, or transactional + - Ensure the content fully addresses that specific intent + - Provide clear next steps for the reader based on their likely stage in the buyer's journey +""" # noqa: E501 + + +agent = Agent( + get_default_ai_model(), + output_type=GeneratedBlogPostSchema, + deps_type=BlogPostGenerationContext, + system_prompt=seo_content_prompt, + retries=2, + model_settings={"max_tokens": 65500, "temperature": 0.8}, +) + +agent.system_prompt(add_project_details) +agent.system_prompt(add_title_details) +agent.system_prompt(add_todays_date) +agent.system_prompt(add_language_specification) +agent.system_prompt(add_target_keywords) +agent.system_prompt(valid_markdown_format) +agent.system_prompt(markdown_lists) +agent.system_prompt(post_structure) +agent.system_prompt(filler_content) +agent.system_prompt(inline_link_formatting) + + +def create_structure_guidance_prompt(structure_dict: dict) -> callable: + """ + Creates a system prompt function that includes the blog post structure. + + Args: + structure_dict: The structure outline as a dictionary + + Returns: + A function that can be used as a system prompt + """ + + def add_structure_guidance(ctx: RunContext[BlogPostGenerationContext]) -> str: + return f""" + IMPORTANT: Follow this detailed structure outline: + + {json.dumps(structure_dict, indent=2)} + + Make sure to: + - Follow the section headings and their hierarchy (H2 vs H3) + - Cover all the key points listed for each section + - Aim for the target word counts specified + - Follow the introduction and conclusion guidance provided + - Focus on the SEO keywords mentioned in the structure + """ + + return add_structure_guidance diff --git a/core/agents/summarize_page_agent.py b/core/agents/summarize_page_agent.py new file mode 100644 index 0000000..881f594 --- /dev/null +++ b/core/agents/summarize_page_agent.py @@ -0,0 +1,19 @@ +from pydantic_ai import Agent + +from core.agents.system_prompts import add_webpage_content +from core.choices import get_default_ai_model +from core.schemas import ProjectPageDetails, WebPageContent + +agent = Agent( + get_default_ai_model(), + output_type=ProjectPageDetails, + deps_type=WebPageContent, + system_prompt=( + "You are an expert content summarizer. Based on the web page content provided, " + "create a concise 2-3 sentence summary that captures the main purpose and key " + "information of the page. Focus on what the page is about and its main value proposition." + ), + retries=2, + model_settings={"temperature": 0.5}, +) +agent.system_prompt(add_webpage_content) diff --git a/core/agent_system_prompts.py b/core/agents/system_prompts.py similarity index 89% rename from core/agent_system_prompts.py rename to core/agents/system_prompts.py index d64e7e6..8be2372 100644 --- a/core/agent_system_prompts.py +++ b/core/agents/system_prompts.py @@ -160,3 +160,20 @@ def add_webpage_content(ctx: RunContext[WebPageContent]) -> str: f"Description: {ctx.deps.description}" f"Content: {ctx.deps.markdown_content}" ) + + +def inline_link_formatting() -> str: + return """ + LINK FORMATTING: + Insert links INLINE with natural anchor text in markdown format. + + CORRECT examples: + - "...you can learn more about this feature [here](https://example.com)..." + - "...we recommend you [give it a try](https://example.com) to see the benefits..." + - "...check out our [pricing page](https://example.com) for more details..." + + INCORRECT examples (DO NOT use these formats): + - "...learn more here: https://example.com..." + - "...give it a try: https://example.com..." + - "...pricing page: https://example.com..." + """ diff --git a/core/agents/title_suggestions_agent.py b/core/agents/title_suggestions_agent.py new file mode 100644 index 0000000..790407e --- /dev/null +++ b/core/agents/title_suggestions_agent.py @@ -0,0 +1,153 @@ +from pydantic_ai import Agent, RunContext + +from core.agents.system_prompts import add_todays_date +from core.choices import get_default_ai_model +from core.schemas import TitleSuggestionContext, TitleSuggestions + +SEO_TITLE_PROMPT = """ +You are an expert SEO content strategist and blog title generator. Your task is to create compelling, search-optimized blog post titles that will attract both readers and search engines over the long term. + +1. TIMELESS APPEAL: Create titles that will remain relevant for years, avoiding trendy phrases, years, or time-specific references unless absolutely necessary for the topic. + +2. SEARCH INTENT ALIGNMENT: Craft titles that clearly address one of these search intents: + - Informational (how-to, guides, explanations) + - Navigational (finding specific resources) + - Commercial (comparing options, reviews) + - Transactional (looking to take action) + +3. KEYWORD OPTIMIZATION: + - Include the primary keyword naturally, preferably near the beginning + - Incorporate relevant secondary keywords where appropriate + - Avoid keyword stuffing that makes titles sound unnatural + +4. TITLE STRUCTURE: + - Keep titles between 50-60 characters (approximately 10-12 words) + - Use power words that evoke emotion (essential, ultimate, proven, etc.) + - Consider using numbers in list-based titles (odd numbers often perform better) + - Use brackets or parentheses for clarification when helpful [Template], (Case Study) + +5. CLICK-WORTHINESS: + - Create a sense of value (comprehensive, definitive, etc.) + - Hint at solving a problem or fulfilling a need + - Avoid clickbait tactics that overpromise + - Maintain clarity - readers should know exactly what they'll get + +6. VARIETY OF FORMATS: + - How-to guides ("How to [Achieve Result] with [Method]") + - List posts ("X Ways to [Solve Problem]") + - Ultimate guides ("The Complete Guide to [Topic]") + - Question-based titles ("Why Does [Topic] Matter for [Audience]?") + - Problem-solution ("Struggling with [Problem]? Try These [Solutions]") + +For each title suggestion, provide a brief explanation (1-2 sentences) of why it would perform well from an SEO perspective. + +Here's information about my blog topic: +[I'll provide my blog topic, target audience, primary keywords, and any specific goals] +""" # noqa: E501 + + +agent = Agent( + get_default_ai_model(), + output_type=TitleSuggestions, + deps_type=TitleSuggestionContext, + system_prompt=SEO_TITLE_PROMPT, + retries=2, + model_settings={"temperature": 0.9}, +) + +agent.system_prompt(add_todays_date) + + +@agent.system_prompt +def add_project_details(ctx: RunContext[TitleSuggestionContext]) -> str: + project = ctx.deps.project_details + return f""" + Project Details: + - Project Name: {project.name} + - Project Type: {project.type} + - Project Summary: {project.summary} + - Blog Theme: {project.blog_theme} + - Founders: {project.founders} + - Key Features: {project.key_features} + - Target Audience: {project.target_audience_summary} + - Pain Points: {project.pain_points} + - Product Usage: {project.product_usage} + """ + + +@agent.system_prompt +def add_number_of_titles_to_generate(ctx: RunContext[TitleSuggestionContext]) -> str: + return f"""IMPORTANT: Generate only {ctx.deps.num_titles} titles.""" + + +@agent.system_prompt +def add_language_specification(ctx: RunContext[TitleSuggestionContext]) -> str: + project = ctx.deps.project_details + return f""" + IMPORTANT: Generate all titles in {project.language} language. + Make sure the titles are grammatically correct and culturally + appropriate for {project.language}-speaking audiences. + """ + + +@agent.system_prompt +def add_user_prompt(ctx: RunContext[TitleSuggestionContext]) -> str: + if not ctx.deps.user_prompt: + return "" + + return f""" + IMPORTANT USER REQUEST: The user has specifically requested the following: + "{ctx.deps.user_prompt}" + + This is a high-priority requirement. Make sure to incorporate this guidance + when generating titles while still maintaining SEO best practices and readability. + """ + + +@agent.system_prompt +def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: + feedback_sections = [] + + if ctx.deps.neutral_suggestions: + neutral = "\n".join(f"- {title}" for title in ctx.deps.neutral_suggestions) + feedback_sections.append( + f""" + Title Suggestions that users have not yet liked or disliked: + {neutral} + """ + ) + + if ctx.deps.liked_suggestions: + liked = "\n".join(f"- {title}" for title in ctx.deps.liked_suggestions) + feedback_sections.append( + f""" + Liked Title Suggestions: + {liked} + """ + ) + + if ctx.deps.disliked_suggestions: + disliked = "\n".join(f"- {title}" for title in ctx.deps.disliked_suggestions) + feedback_sections.append( + f""" + Disliked Title Suggestions: + {disliked} + """ + ) + + if feedback_sections: + feedback_sections.append( + """ + Use this feedback to guide your title generation. + Create titles that are thematically similar to the "Liked" titles, + and avoid any stylistic or thematic patterns from the "Disliked" titles. + + IMPORTANT! + You must generate completely new and unique titles. + Do not repeat or create minor variations of any titles listed above in the + "Previously Generated", "Liked", or "Disliked" sections. + Your primary goal is originality. + """ + ) + + return "\n".join(feedback_sections) diff --git a/core/api/schemas.py b/core/api/schemas.py index b0fd090..b2547c1 100644 --- a/core/api/schemas.py +++ b/core/api/schemas.py @@ -48,7 +48,7 @@ class ProjectScanOut(Schema): class GenerateTitleSuggestionsIn(Schema): project_id: int - content_type: str = ContentType.SHARING + content_type: str = ContentType.SEO user_prompt: str = "" num_titles: int = 3 @@ -76,16 +76,6 @@ class GenerateTitleSuggestionsOut(Schema): message: str = "" -class GeneratedContentOut(Schema): - status: str = "success" - message: str | None = None - content: str | None = None - slug: str | None = None - tags: str | None = None - description: str | None = None - id: int | None = None - - class UpdateTitleScoreIn(Schema): score: int @@ -131,16 +121,6 @@ class CompetitorAnalysisOut(Schema): key_drawbacks: str | None = None -class GenerateCompetitorVsTitleIn(Schema): - competitor_id: int - - -class GenerateCompetitorVsTitleOut(Schema): - status: str - message: str = "" - competitor_id: int | None = None - - class SubmitFeedbackIn(Schema): feedback: str page: str @@ -231,15 +211,6 @@ class ToggleOGImageGenerationOut(Schema): message: str = "" -class FixGeneratedBlogPostIn(Schema): - id: int - - -class FixGeneratedBlogPostOut(Schema): - status: str - message: str - - class GetKeywordDetailsOut(Schema): status: str message: str | None = None @@ -278,3 +249,42 @@ class GenerateOGImageOut(Schema): status: str message: str = "" image_url: str = "" + + +# Pipeline API Schemas + + +class PipelineStartOut(Schema): + status: str + message: str = "" + blog_post_id: int | None = None + pipeline_state: dict | None = None + + +class PipelineStepOut(Schema): + status: str + message: str = "" + step_name: str = "" + pipeline_state: dict | None = None + result: dict | None = None + + +class PipelineStatusOut(Schema): + status: str + current_step: str | None = None + steps: dict | None = None + progress_percentage: int = 0 + metadata: dict | None = None + + +class PipelineRetryOut(Schema): + status: str + message: str = "" + pipeline_state: dict | None = None + + +class PipelineCompleteOut(Schema): + status: str + message: str = "" + blog_post_id: int | None = None + blog_post: dict | None = None diff --git a/core/api/views.py b/core/api/views.py index b49eeb9..5d7b560 100644 --- a/core/api/views.py +++ b/core/api/views.py @@ -19,17 +19,17 @@ CompetitorAnalysisOut, DeleteProjectKeywordIn, DeleteProjectKeywordOut, - FixGeneratedBlogPostIn, - FixGeneratedBlogPostOut, - GenerateCompetitorVsTitleIn, - GenerateCompetitorVsTitleOut, - GeneratedContentOut, GenerateOGImageIn, GenerateOGImageOut, GenerateTitleSuggestionOut, GenerateTitleSuggestionsIn, GenerateTitleSuggestionsOut, GetKeywordDetailsOut, + PipelineCompleteOut, + PipelineRetryOut, + PipelineStartOut, + PipelineStatusOut, + PipelineStepOut, PostGeneratedBlogPostIn, PostGeneratedBlogPostOut, ProjectScanIn, @@ -50,7 +50,7 @@ ValidateUrlIn, ValidateUrlOut, ) -from core.choices import ContentType, ProjectPageType +from core.choices import ProjectPageType from core.models import ( BlogPost, BlogPostTitleSuggestion, @@ -215,16 +215,6 @@ def generate_title_suggestions(request: HttpRequest, data: GenerateTitleSuggesti profile = request.auth project = get_object_or_404(Project, id=data.project_id, profile=profile) - try: - content_type = ContentType[data.content_type] - except KeyError: - return { - "suggestions": [], - "suggestions_html": [], - "status": "error", - "message": f"Invalid content type: {data.content_type}", - } - if not profile.can_generate_title_suggestions: limit = profile.title_suggestion_limit current_count = profile.number_of_title_suggestions_this_month @@ -243,9 +233,7 @@ def generate_title_suggestions(request: HttpRequest, data: GenerateTitleSuggesti ) titles_to_generate = min(data.num_titles, remaining_ideas) - suggestions = project.generate_title_suggestions( - content_type=content_type, num_titles=titles_to_generate - ) + suggestions = project.generate_title_suggestions(num_titles=titles_to_generate) # Render HTML for each suggestion using the Django template suggestions_html = [] @@ -281,14 +269,7 @@ def generate_title_from_idea(request: HttpRequest, data: GenerateTitleSuggestion } try: - try: - content_type = ContentType[data.content_type] - except KeyError: - return {"status": "error", "message": f"Invalid content type: {data.content_type}"} - - suggestions = project.generate_title_suggestions( - content_type=content_type, num_titles=1, user_prompt=data.user_prompt - ) + suggestions = project.generate_title_suggestions(num_titles=1, user_prompt=data.user_prompt) if not suggestions: return {"status": "error", "message": "No suggestions were generated"} @@ -328,62 +309,6 @@ def generate_title_from_idea(request: HttpRequest, data: GenerateTitleSuggestion raise e -@api.post( - "/generate-blog-content/{suggestion_id}", response=GeneratedContentOut, auth=[session_auth] -) -def generate_blog_content(request: HttpRequest, suggestion_id: int): - profile = request.auth - suggestion = get_object_or_404( - BlogPostTitleSuggestion, id=suggestion_id, project__profile=profile - ) - - if profile.reached_content_generation_limit: - limit = profile.blog_post_generation_limit - current_count = profile.number_of_generated_blog_posts_this_month - message = f"Content generation limit reached ({current_count}/{limit} blog posts this month on Free plan). Upgrade to Pro for unlimited content." # noqa: E501 - return { - "status": "error", - "message": message, - } - - try: - blog_post = suggestion.generate_content(content_type=suggestion.content_type) - - if not blog_post or not blog_post.content: - return {"status": "error", "message": "Failed to generate content. Please try again."} - - return { - "status": "success", - "id": blog_post.id, - "content": blog_post.content, - "slug": blog_post.slug, - "tags": blog_post.tags, - "description": blog_post.description, - } - - except ValueError as e: - logger.error( - "Failed to generate blog content", - error=str(e), - exc_info=True, - suggestion_id=suggestion_id, - profile_id=profile.id, - ) - return {"status": "error", "message": str(e)} - except Exception as e: - logger.error( - "Unexpected error generating blog content", - error=str(e), - exc_info=True, - suggestion_id=suggestion_id, - profile_id=profile.id, - ) - return { - "status": "error", - "message": "An unexpected error occurred. Please try again later.", - } - - @api.post("/projects/{project_id}/update", response={200: dict}, auth=[session_auth]) def update_project(request: HttpRequest, project_id: int): profile = request.auth @@ -653,75 +578,6 @@ def add_competitor(request: HttpRequest, data: AddCompetitorIn): return {"status": "error", "message": f"An unexpected error occurred: {str(e)}"} -@api.post( - "/generate-competitor-vs-title", response=GenerateCompetitorVsTitleOut, auth=[session_auth] -) -def generate_competitor_vs_title(request: HttpRequest, data: GenerateCompetitorVsTitleIn): - """Generate a competitor comparison blog post using Perplexity Sonar.""" - profile = request.auth - competitor = get_object_or_404(Competitor, id=data.competitor_id) - project = competitor.project - - if project.profile != profile: - return { - "status": "error", - "message": "You do not have permission to access this competitor", - } - - # Check if user has reached competitor posts generation limit - if not profile.can_generate_competitor_posts: - return { - "status": "error", - "message": f"You have reached the competitor post generation limit for your {profile.product_name} plan. Please upgrade to generate more competitor comparison posts.", # noqa: E501 - } - - if not profile.can_generate_blog_posts: - return { - "status": "error", - "message": f"You have reached the content generation limit for your {profile.product_name} plan", # noqa: E501 - } - - try: - logger.info( - "Generating VS competitor blog post", - competitor_id=competitor.id, - competitor_name=competitor.name, - project_id=project.id, - profile_id=profile.id, - ) - - blog_post_content = competitor.generate_vs_blog_post() - - logger.info( - "VS competitor blog post generated successfully", - competitor_id=competitor.id, - competitor_name=competitor.name, - content_length=len(blog_post_content), - project_id=project.id, - profile_id=profile.id, - ) - - return { - "status": "success", - "message": "VS blog post generated successfully!", - "competitor_id": competitor.id, - } - - except Exception as e: - logger.error( - "Failed to generate competitor vs. blog post", - error=str(e), - exc_info=True, - competitor_id=data.competitor_id, - project_id=project.id, - profile_id=profile.id, - ) - return { - "status": "error", - "message": f"Failed to generate competitor comparison blog post: {str(e)}", - } - - @api.post("/submit-feedback", auth=[session_auth]) def submit_feedback(request: HttpRequest, data: SubmitFeedbackIn): profile = request.auth @@ -1041,40 +897,6 @@ def post_generated_blog_post(request: HttpRequest, data: PostGeneratedBlogPostIn return {"status": "error", "message": str(e)} -@api.post("/fix-generated-blog-post", response=FixGeneratedBlogPostOut, auth=[session_auth]) -def fix_generated_blog_post(request: HttpRequest, data: FixGeneratedBlogPostIn): - profile = request.auth - - blog_post_id = data.id - if not blog_post_id: - return {"status": "error", "message": "Missing generated blog post id."} - - try: - generated_post = GeneratedBlogPost.objects.get(id=blog_post_id) - if generated_post.project and generated_post.project.profile != profile: - return {"status": "error", "message": "Forbidden: You do not have access to this post."} - - # Check if there are actually issues to fix - if generated_post.blog_post_content_is_valid: - return {"status": "success", "message": "Blog post content is already valid."} - - # Run the fix method - generated_post.fix_generated_blog_post() - - return {"status": "success", "message": "Blog post issues have been fixed successfully."} - - except GeneratedBlogPost.DoesNotExist: - return {"status": "error", "message": "Generated blog post not found."} - except Exception as e: - logger.error( - "Failed to fix generated blog post", - error=str(e), - blog_post_id=blog_post_id, - exc_info=True, - ) - return {"status": "error", "message": f"Failed to fix blog post: {str(e)}"} - - @api.post( "/project-pages/toggle-always-use", response=ToggleProjectPageAlwaysUseOut, auth=[session_auth] ) @@ -1190,3 +1012,349 @@ def generate_og_image(request: HttpRequest, data: GenerateOGImageIn): "status": "error", "message": f"An unexpected error occurred: {str(error)}", } + + +######################################################## +# Blog Post Generation Pipeline Endpoints +######################################################## + + +@api.post( + "/generate-blog-content-pipeline/{suggestion_id}/start", + response=PipelineStartOut, + auth=[session_auth], +) +def start_pipeline(request: HttpRequest, suggestion_id: int): + """ + Initialize a new blog post generation pipeline. + + Creates a GeneratedBlogPost with initialized pipeline state. + """ + profile = request.auth + suggestion = get_object_or_404( + BlogPostTitleSuggestion, id=suggestion_id, project__profile=profile + ) + + if profile.reached_content_generation_limit: + limit = profile.blog_post_generation_limit + current_count = profile.number_of_generated_blog_posts_this_month + message = f"Content generation limit reached ({current_count}/{limit} blog posts this month on Free plan). Upgrade to Pro for unlimited content." # noqa: E501 + return { + "status": "error", + "message": message, + "blog_post_id": None, + "pipeline_state": None, + } + + try: + blog_post = suggestion.start_generation_pipeline() + pipeline_state = blog_post.get_pipeline_status() + + logger.info( + "[Pipeline API] Started pipeline", + blog_post_id=blog_post.id, + suggestion_id=suggestion_id, + profile_id=profile.id, + ) + + return { + "status": "success", + "message": "Pipeline initialized successfully", + "blog_post_id": blog_post.id, + "pipeline_state": pipeline_state, + } + + except Exception as error: + logger.error( + "[Pipeline API] Failed to start pipeline", + error=str(error), + exc_info=True, + suggestion_id=suggestion_id, + profile_id=profile.id, + ) + return { + "status": "error", + "message": f"Failed to start pipeline: {str(error)}", + "blog_post_id": None, + "pipeline_state": None, + } + + +@api.post( + "/generate-blog-content-pipeline/{blog_post_id}/step/{step_name}", + response=PipelineStepOut, + auth=[session_auth], +) +def execute_pipeline_step(request: HttpRequest, blog_post_id: int, step_name: str): + """ + Execute a specific pipeline step. + + Valid step names: structure, content, preliminary_validation, internal_links, final_validation + """ + profile = request.auth + blog_post = get_object_or_404(GeneratedBlogPost, id=blog_post_id, project__profile=profile) + + step_map = { + "structure": ("generate_structure", "Structure generated successfully"), + "content": ("generate_content_from_structure", "Content generated successfully"), + "preliminary_validation": ( + "run_preliminary_validation", + "Preliminary validation completed", + ), + "fix_preliminary_validation": ( + "fix_preliminary_validation", + "Preliminary validation issues fixed", + ), + "internal_links": ("insert_internal_links", "Internal links inserted successfully"), + "final_validation": ("run_final_validation", "Final validation completed"), + "fix_final_validation": ("fix_final_validation", "Final validation issues fixed"), + } + + if step_name not in step_map: + return { + "status": "error", + "message": f"Invalid step name: {step_name}", + "step_name": step_name, + "pipeline_state": None, + "result": None, + } + + method_name, success_message = step_map[step_name] + + try: + # Get the method from the title suggestion + suggestion = blog_post.title + method = getattr(suggestion, method_name) + + # Execute the step + if "fix_" in step_name: + # For fix steps, we need to get validation issues from the pipeline state + validation_step_name = step_name.replace("fix_", "") + step_info = blog_post.pipeline_state.get("steps", {}).get(validation_step_name, {}) + error_message = step_info.get("error", "") + + # Extract validation issues from error message + validation_issues = [] + if error_message.startswith("Content validation failed - "): + issues_text = error_message.replace("Content validation failed - ", "") + validation_issues = [issue.strip() for issue in issues_text.split(";")] + + if not validation_issues: + return { + "status": "error", + "message": "No validation issues found to fix", + "step_name": step_name, + "pipeline_state": blog_post.get_pipeline_status(), + "result": None, + } + + result = method(blog_post, validation_issues) + else: + result = method(blog_post) + + # Check if validation step failed + if step_name in ["preliminary_validation", "final_validation"]: + if isinstance(result, dict) and not result.get("is_valid"): + # Get the updated pipeline state to check actual status + pipeline_state = blog_post.get_pipeline_status() + actual_step_status = ( + pipeline_state.get("steps", {}).get(step_name, {}).get("status") + ) + + # Only return needs_fix if the step is actually in needs_fix status + # If it's "failed", that means we've exceeded fix attempts + if actual_step_status == "needs_fix": + return { + "status": "success", + "message": f"{success_message} - validation found issues", + "step_name": step_name, + "pipeline_state": pipeline_state, + "result": result, + "needs_fix": True, + "validation_issues": result.get("validation_issues", []), + } + # If status is "failed", let it fall through to normal error handling below + + # Get updated pipeline state + pipeline_state = blog_post.get_pipeline_status() + + logger.info( + "[Pipeline API] Step executed successfully", + blog_post_id=blog_post_id, + step_name=step_name, + profile_id=profile.id, + ) + + return { + "status": "success", + "message": success_message, + "step_name": step_name, + "pipeline_state": pipeline_state, + "result": {"data": str(result)[:200]} + if result and not isinstance(result, dict) + else result, + } + + except Exception as error: + logger.error( + "[Pipeline API] Step execution failed", + error=str(error), + exc_info=True, + blog_post_id=blog_post_id, + step_name=step_name, + profile_id=profile.id, + ) + + # Update pipeline step to failed + blog_post.refresh_from_db() + pipeline_state = blog_post.get_pipeline_status() + + return { + "status": "error", + "message": f"Step failed: {str(error)}", + "step_name": step_name, + "pipeline_state": pipeline_state, + "result": None, + } + + +@api.get( + "/generate-blog-content-pipeline/{blog_post_id}/status", + response=PipelineStatusOut, + auth=[session_auth], +) +def get_pipeline_status(request: HttpRequest, blog_post_id: int): + """Get the current status of the pipeline.""" + profile = request.auth + blog_post = get_object_or_404(GeneratedBlogPost, id=blog_post_id, project__profile=profile) + + try: + pipeline_status = blog_post.get_pipeline_status() + + return { + "status": "success", + "current_step": pipeline_status.get("current_step"), + "steps": pipeline_status.get("steps"), + "progress_percentage": pipeline_status.get("progress_percentage"), + "metadata": pipeline_status.get("metadata"), + } + + except Exception as error: + logger.error( + "[Pipeline API] Failed to get pipeline status", + error=str(error), + exc_info=True, + blog_post_id=blog_post_id, + profile_id=profile.id, + ) + return { + "status": "error", + "current_step": None, + "steps": None, + "progress_percentage": 0, + "metadata": None, + } + + +@api.post( + "/generate-blog-content-pipeline/{blog_post_id}/retry/{step_name}", + response=PipelineRetryOut, + auth=[session_auth], +) +def retry_pipeline_step(request: HttpRequest, blog_post_id: int, step_name: str): + """Retry a failed pipeline step if retry count < 3.""" + profile = request.auth + blog_post = get_object_or_404(GeneratedBlogPost, id=blog_post_id, project__profile=profile) + + # Check if step can be retried + if not blog_post.can_retry_step(step_name): + return { + "status": "error", + "message": "Step cannot be retried (max 3 attempts reached)", + "pipeline_state": blog_post.get_pipeline_status(), + } + + # Reset step status to pending + blog_post.update_pipeline_step(step_name, "pending") + + logger.info( + "[Pipeline API] Step reset for retry", + blog_post_id=blog_post_id, + step_name=step_name, + profile_id=profile.id, + ) + + return { + "status": "success", + "message": f"Step {step_name} reset and ready for retry", + "pipeline_state": blog_post.get_pipeline_status(), + } + + +@api.post( + "/generate-blog-content-pipeline/{suggestion_id}/complete", + response=PipelineCompleteOut, + auth=[session_auth], +) +def complete_pipeline(request: HttpRequest, suggestion_id: int): + """ + Execute the complete pipeline sequentially without UI updates. + + This is useful for scheduled tasks or when you want to run + the entire pipeline in one go. + """ + profile = request.auth + suggestion = get_object_or_404( + BlogPostTitleSuggestion, id=suggestion_id, project__profile=profile + ) + + if profile.reached_content_generation_limit: + limit = profile.blog_post_generation_limit + current_count = profile.number_of_generated_blog_posts_this_month + message = f"Content generation limit reached ({current_count}/{limit} blog posts this month on Free plan). Upgrade to Pro for unlimited content." # noqa: E501 + return { + "status": "error", + "message": message, + "blog_post_id": None, + "blog_post": None, + } + + try: + blog_post = suggestion.execute_complete_pipeline() + + logger.info( + "[Pipeline API] Complete pipeline executed successfully", + blog_post_id=blog_post.id, + suggestion_id=suggestion_id, + profile_id=profile.id, + ) + + return { + "status": "success", + "message": "Blog post generated successfully", + "blog_post_id": blog_post.id, + "blog_post": { + "id": blog_post.id, + "slug": blog_post.slug, + "title": blog_post.post_title, + "description": blog_post.description, + "tags": blog_post.tags, + "content_length": len(blog_post.content), + "is_valid": blog_post.blog_post_content_is_valid, + }, + } + + except Exception as error: + logger.error( + "[Pipeline API] Complete pipeline failed", + error=str(error), + exc_info=True, + suggestion_id=suggestion_id, + profile_id=profile.id, + ) + return { + "status": "error", + "message": f"Pipeline execution failed: {str(error)}", + "blog_post_id": None, + "blog_post": None, + } diff --git a/core/choices.py b/core/choices.py index 883d4ee..1e53da0 100644 --- a/core/choices.py +++ b/core/choices.py @@ -2,7 +2,6 @@ class ContentType(models.TextChoices): - SHARING = "SHARING", "Sharing" SEO = "SEO", "SEO" diff --git a/core/constants.py b/core/constants.py index d851e3d..e69de29 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,52 +0,0 @@ -PLACEHOLDER_PATTERNS = [ - "insert image here", - "insert image", - "add image here", - "add image", - "image placeholder", - "placeholder image", - "[image]", - "[insert image]", - "example 1", - "example 2", - "example 3", - "example here", - "add example", - "insert example", - "[example]", - "todo:", - "to do:", - "tbd", - "to be determined", - "coming soon", - "under construction", - "placeholder text", - "lorem ipsum", - "sample text", - "dummy text", - "[placeholder]", - "[todo]", - "[tbd]", - "fill in", - "replace this", - "update this", - "change this", - "modify this", - "edit this", - "xxx", - "yyy", - "zzz", - "Image Suggestion", - "Link Suggestion", - "image suggestion", - "link suggestion", -] - -PLACEHOLDER_BRACKET_PATTERNS = [ - r"\[.*?placeholder.*?\]", - r"\[.*?insert.*?\]", - r"\[.*?add.*?\]", - r"\[.*?todo.*?\]", - r"\[.*?tbd.*?\]", - r"\[.*?example.*?\]", -] diff --git a/core/migrations/0042_generatedblogpost_generation_structure_and_more.py b/core/migrations/0042_generatedblogpost_generation_structure_and_more.py new file mode 100644 index 0000000..f436c97 --- /dev/null +++ b/core/migrations/0042_generatedblogpost_generation_structure_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2025-11-07 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0041_competitor_embedding_projectpage_embedding_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='generatedblogpost', + name='generation_structure', + field=models.TextField(blank=True, default='', help_text='Stores the outline/structure from the first pipeline step'), + ), + migrations.AddField( + model_name='generatedblogpost', + name='pipeline_metadata', + field=models.JSONField(blank=True, default=dict, help_text='Stores timestamps, AI model info, and token usage per step'), + ), + migrations.AddField( + model_name='generatedblogpost', + name='pipeline_state', + field=models.JSONField(blank=True, default=None, help_text='Tracks the current step and status of the generation pipeline', null=True), + ), + migrations.AddField( + model_name='generatedblogpost', + name='raw_content', + field=models.TextField(blank=True, default='', help_text='Stores content before internal links are added'), + ), + ] diff --git a/core/migrations/0043_remove_generatedblogpost_content_too_short_and_more.py b/core/migrations/0043_remove_generatedblogpost_content_too_short_and_more.py new file mode 100644 index 0000000..2a42fe1 --- /dev/null +++ b/core/migrations/0043_remove_generatedblogpost_content_too_short_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.7 on 2025-11-08 10:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0042_generatedblogpost_generation_structure_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='generatedblogpost', + name='content_too_short', + ), + migrations.RemoveField( + model_name='generatedblogpost', + name='has_valid_ending', + ), + migrations.RemoveField( + model_name='generatedblogpost', + name='placeholders', + ), + migrations.RemoveField( + model_name='generatedblogpost', + name='starts_with_header', + ), + migrations.AlterField( + model_name='blogposttitlesuggestion', + name='content_type', + field=models.CharField(choices=[('SEO', 'SEO')], default='SEO', max_length=20), + ), + ] diff --git a/core/models.py b/core/models.py index a438917..4a6124e 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal, InvalidOperation import requests @@ -9,27 +10,9 @@ from django_q.tasks import async_task from pgvector.django import HnswIndex, VectorField from pydantic_ai import Agent, RunContext -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -from core.agent_system_prompts import ( - add_todays_date, - filler_content, - markdown_lists, - post_structure, - valid_markdown_format, -) -from core.agents import ( - add_language_specification, - add_project_details, - add_project_pages, - add_target_keywords, - add_title_details, - content_editor_agent, -) + from core.base_models import BaseModel from core.choices import ( - AIModel, BlogPostStatus, Category, ContentType, @@ -49,10 +32,6 @@ get_markdown_content, run_agent_synchronously, ) -from core.prompts import ( - GENERATE_CONTENT_SYSTEM_PROMPTS, - TITLE_SUGGESTION_SYSTEM_PROMPTS, -) from core.schemas import ( BlogPostGenerationContext, CompetitorAnalysis, @@ -63,7 +42,6 @@ ProjectPageContext, TitleSuggestion, TitleSuggestionContext, - TitleSuggestions, WebPageContent, ) from tuxseo.utils import get_tuxseo_logger @@ -425,6 +403,7 @@ def project_details(self): language=self.language, proposed_keywords=self.proposed_keywords, location=self.location, + is_on_free_plan=self.profile.is_on_free_plan, ) @property @@ -497,10 +476,10 @@ def analyze_content(self): Analyze the page content using PydanticAI and update project details. Should be called after get_page_content(). """ - from core.agents import analyze_project_agent + from core.agents.analyze_project_agent import agent result = run_agent_synchronously( - analyze_project_agent, + agent, "Analyze this web page content and extract the key information.", deps=WebPageContent( title=self.title, @@ -534,111 +513,11 @@ def analyze_content(self): return True - def generate_title_suggestions( # noqa: C901 - self, content_type=ContentType.SHARING, num_titles=3, user_prompt="", model=None - ): - agent = Agent( - model or get_default_ai_model(), - output_type=TitleSuggestions, - deps_type=TitleSuggestionContext, - system_prompt=TITLE_SUGGESTION_SYSTEM_PROMPTS[content_type], - retries=2, - model_settings={"temperature": 0.9}, - ) - - agent.system_prompt(add_todays_date) - - @agent.system_prompt - def add_project_details(ctx: RunContext[TitleSuggestionContext]) -> str: - project = ctx.deps.project_details - return f""" - Project Details: - - Project Name: {project.name} - - Project Type: {project.type} - - Project Summary: {project.summary} - - Blog Theme: {project.blog_theme} - - Founders: {project.founders} - - Key Features: {project.key_features} - - Target Audience: {project.target_audience_summary} - - Pain Points: {project.pain_points} - - Product Usage: {project.product_usage} - """ - - @agent.system_prompt - def add_number_of_titles_to_generate(ctx: RunContext[TitleSuggestionContext]) -> str: - return f"""IMPORTANT: Generate only {ctx.deps.num_titles} titles.""" - - @agent.system_prompt - def add_language_specification(ctx: RunContext[TitleSuggestionContext]) -> str: - project = ctx.deps.project_details - return f""" - IMPORTANT: Generate all titles in {project.language} language. - Make sure the titles are grammatically correct and culturally - appropriate for {project.language}-speaking audiences. - """ - - @agent.system_prompt - def add_user_prompt(ctx: RunContext[TitleSuggestionContext]) -> str: - if not ctx.deps.user_prompt: - return "" - - return f""" - IMPORTANT USER REQUEST: The user has specifically requested the following: - "{ctx.deps.user_prompt}" - - This is a high-priority requirement. Make sure to incorporate this guidance - when generating titles while still maintaining SEO best practices and readability. - """ - - @agent.system_prompt - def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: - # Build the feedback sections only if they exist - feedback_sections = [] - - if ctx.deps.neutral_suggestions: - neutral = "\n".join(f"- {title}" for title in ctx.deps.neutral_suggestions) - feedback_sections.append( - f""" - Title Suggestions that users have not yet liked or disliked: - {neutral} - """ - ) - - if ctx.deps.liked_suggestions: - liked = "\n".join(f"- {title}" for title in ctx.deps.liked_suggestions) - feedback_sections.append( - f""" - Liked Title Suggestions: - {liked} - """ - ) - - if ctx.deps.disliked_suggestions: - disliked = "\n".join(f"- {title}" for title in ctx.deps.disliked_suggestions) - feedback_sections.append( - f""" - Disliked Title Suggestions: - {disliked} - """ - ) - - # Add guidance only if we have any feedback - if feedback_sections: - feedback_sections.append( - """ - Use this feedback to guide your title generation. - Create titles that are thematically similar to the "Liked" titles, - and avoid any stylistic or thematic patterns from the "Disliked" titles. - - IMPORTANT! - You must generate completely new and unique titles. - Do not repeat or create minor variations of any titles listed above in the - "Previously Generated", "Liked", or "Disliked" sections. - Your primary goal is originality. - """ - ) + def generate_title_suggestions(self, num_titles=3, user_prompt="", model=None): + from core.agents.title_suggestions_agent import agent as title_suggestions_agent - return "\n".join(feedback_sections) + if model: + title_suggestions_agent.model = model deps = TitleSuggestionContext( project_details=self.project_details, @@ -652,7 +531,7 @@ def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: ) result = run_agent_synchronously( - agent, + title_suggestions_agent, "Please generate blog post title suggestions based on the project details.", deps=deps, function_name="generate_title_suggestions", @@ -667,7 +546,7 @@ def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: title=title.title, description=title.description, category=title.category, - content_type=content_type, + content_type=ContentType.SEO, target_keywords=title.target_keywords, prompt=user_prompt, suggested_meta_description=title.suggested_meta_description, @@ -712,77 +591,7 @@ def add_links_text(ctx: RunContext[str]) -> str: return result.output def find_competitors(self): - model = OpenAIChatModel( - AIModel.PERPLEXITY_SONAR, - provider=OpenAIProvider( - base_url="https://api.perplexity.ai", - api_key=settings.PERPLEXITY_API_KEY, - ), - ) - agent = Agent( - model, - deps_type=ProjectDetails, - output_type=str, - system_prompt=""" - You are a helpful assistant that helps me find competitors for my project. - """, - retries=2, - ) - - @agent.system_prompt - def add_project_details(ctx: RunContext[ProjectDetails]) -> str: - project = ctx.deps - return f"""I'm working on a project which has the following attributes: - Name: - {project.name} - - Summary: - {project.summary} - - Key Features: - {project.key_features} - - Target Audience: - {project.target_audience_summary} - - Pain Points Addressed: - {project.pain_points} - - Language: {project.language} - """ - - @agent.system_prompt - def required_data() -> str: - return "Make sure that each competitor has a name, url, and description." - - @agent.system_prompt - def number_of_competitors(ctx: RunContext[ProjectDetails]) -> str: - # Check if user is on free plan through the project's profile - profile = self.profile - if profile.is_on_free_plan: - return "Give me a list of exactly 3 competitors." - return "Give me a list of at least 20 competitors." - - @agent.system_prompt - def language_specification(ctx: RunContext[ProjectDetails]) -> str: - project = ctx.deps - return f""" - IMPORTANT: Be mindful that competitors are likely to speak in - {project.language} language. - """ - - @agent.system_prompt - def location_specification(ctx: RunContext[ProjectDetails]) -> str: - project = ctx.deps - if project.location != "Global": - return f""" - IMPORTANT: Only return competitors whose target audience is in - {project.location}. - """ - else: - return """ - IMPORTANT: Return competitors from all over the world. - """ + from core.agents.competitor_finder_agent import agent result = run_agent_synchronously( agent, @@ -847,7 +656,7 @@ class BlogPostTitleSuggestion(BaseModel): title = models.CharField(max_length=255) content_type = models.CharField( - max_length=20, choices=ContentType.choices, default=ContentType.SHARING + max_length=20, choices=ContentType.choices, default=ContentType.SEO ) category = models.CharField( max_length=50, choices=Category.choices, default=Category.GENERAL_AUDIENCE @@ -882,77 +691,660 @@ def title_suggestion_schema(self): suggested_meta_description=self.suggested_meta_description, ) - def generate_content(self, content_type=ContentType.SHARING, model=None): - agent = Agent( - model or get_default_ai_model(), - output_type=GeneratedBlogPostSchema, - deps_type=BlogPostGenerationContext, - system_prompt=GENERATE_CONTENT_SYSTEM_PROMPTS[content_type], - retries=2, - model_settings={"max_tokens": 65500, "temperature": 0.8}, + def start_generation_pipeline(self): + """ + Initialize a new blog post generation pipeline. + + Creates a GeneratedBlogPost with initialized pipeline state. + Returns the blog post object ready for pipeline execution. + """ + blog_post = GeneratedBlogPost.objects.create( + project=self.project, + title=self, + description="", + slug="", + tags="", + content="", ) - agent.system_prompt(add_project_details) - agent.system_prompt(add_project_pages) - agent.system_prompt(add_title_details) - agent.system_prompt(add_todays_date) - agent.system_prompt(add_language_specification) - agent.system_prompt(add_target_keywords) - agent.system_prompt(valid_markdown_format) - agent.system_prompt(markdown_lists) - agent.system_prompt(post_structure) - agent.system_prompt(filler_content) + blog_post.initialize_pipeline() - # Get all analyzed project pages (from AI and sitemap sources) - project_pages = [ - ProjectPageContext( - url=page.url, - title=page.title, - description=page.description, - summary=page.summary, - always_use=page.always_use, + logger.info( + "[Pipeline] Started generation pipeline", + blog_post_id=blog_post.id, + project_id=self.project_id, + title_suggestion_id=self.id, + title=self.title, + ) + + return blog_post + + def generate_structure(self, blog_post): + """ + Step 1: Generate the blog post structure/outline. + + Args: + blog_post: The GeneratedBlogPost object to update + + Returns: + The structure as a JSON-serializable dict + """ + from core.agents.blog_structure_agent import agent + + blog_post.update_pipeline_step("generate_structure", "in_progress") + + try: + project_pages = [ + ProjectPageContext( + url=page.url, + title=page.title, + description=page.description, + summary=page.summary, + always_use=page.always_use, + ) + for page in self.project.project_pages.filter(date_analyzed__isnull=False) + ] + + project_keywords = [ + pk.keyword.keyword_text + for pk in self.project.project_keywords.filter(use=True).select_related("keyword") + ] + + deps = BlogPostGenerationContext( + project_details=self.project.project_details, + title_suggestion=self.title_suggestion_schema, + project_pages=project_pages, + content_type=self.content_type, + project_keywords=project_keywords, ) - for page in self.project.project_pages.filter(date_analyzed__isnull=False) - ] - project_keywords = [ - pk.keyword.keyword_text - for pk in self.project.project_keywords.filter(use=True).select_related("keyword") - ] + result = run_agent_synchronously( + agent, + "Please create a comprehensive structure outline for this blog post.", + deps=deps, + function_name="generate_structure", + model_name="BlogPostTitleSuggestion", + ) - deps = BlogPostGenerationContext( - project_details=self.project.project_details, - title_suggestion=self.title_suggestion_schema, - project_pages=project_pages, - content_type=content_type, - project_keywords=project_keywords, - ) + # Convert structure to dict for JSON storage + structure_dict = result.output.model_dump() - result = run_agent_synchronously( + # Save structure to blog post + blog_post.generation_structure = json.dumps(structure_dict, indent=2) + blog_post.save(update_fields=["generation_structure"]) + + blog_post.update_pipeline_step("generate_structure", "completed") + + logger.info( + "[Pipeline] Structure generation completed", + blog_post_id=blog_post.id, + sections_count=len(structure_dict.get("sections", [])), + ) + + return structure_dict + + except Exception as error: + blog_post.update_pipeline_step( + "generate_structure", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Structure generation failed", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def generate_content_from_structure(self, blog_post): + """ + Step 2: Generate the full blog post content based on the structure. + + Args: + blog_post: The GeneratedBlogPost object with structure already generated + + Returns: + The generated content as a string + """ + from core.agents.seo_content_generator_agent import ( agent, - "Please generate an article based on the project details and title suggestions.", - deps=deps, - function_name="generate_content", - model_name="BlogPostTitleSuggestion", + create_structure_guidance_prompt, ) - blog_post = GeneratedBlogPost.objects.create_and_validate( - project=self.project, - title=self, - description=result.output.description, - slug=result.output.slug, - tags=result.output.tags, - content=result.output.content, + blog_post.update_pipeline_step("generate_content", "in_progress") + + try: + # Load the structure + structure_dict = json.loads(blog_post.generation_structure) + + # Add structure-specific system prompt to agent + structure_prompt = create_structure_guidance_prompt(structure_dict) + agent.system_prompt(structure_prompt) + + project_pages = [ + ProjectPageContext( + url=page.url, + title=page.title, + description=page.description, + summary=page.summary, + always_use=page.always_use, + ) + for page in self.project.project_pages.filter(date_analyzed__isnull=False) + ] + + project_keywords = [ + pk.keyword.keyword_text + for pk in self.project.project_keywords.filter(use=True).select_related("keyword") + ] + + deps = BlogPostGenerationContext( + project_details=self.project.project_details, + title_suggestion=self.title_suggestion_schema, + project_pages=project_pages, + content_type=self.content_type, + project_keywords=project_keywords, + ) + + result = run_agent_synchronously( + agent, + "Please generate the full blog post content following the provided structure outline.", # noqa: E501 + deps=deps, + function_name="generate_content_from_structure", + model_name="BlogPostTitleSuggestion", + ) + + # Update blog post with generated content + blog_post.description = result.output.description + blog_post.slug = result.output.slug + blog_post.tags = result.output.tags + blog_post.raw_content = result.output.content + blog_post.content = result.output.content + blog_post.save(update_fields=["description", "slug", "tags", "raw_content", "content"]) + + blog_post.update_pipeline_step("generate_content", "completed") + + logger.info( + "[Pipeline] Content generation completed", + blog_post_id=blog_post.id, + content_length=len(result.output.content), + ) + + return result.output.content + + except Exception as error: + blog_post.update_pipeline_step( + "generate_content", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Content generation failed", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def run_preliminary_validation(self, blog_post): + """ + Step 3: Run preliminary validation on the generated content using AI. + + Args: + blog_post: The GeneratedBlogPost object with content + + Returns: + bool: True if validation passed, False otherwise + """ + from core.agents.content_validation_agent import agent + from core.model_utils import run_agent_synchronously + from core.schemas import ContentValidationContext + + blog_post.update_pipeline_step("preliminary_validation", "in_progress") + + try: + validation_context = ContentValidationContext( + content=blog_post.content, + title=blog_post.title.title, + description=blog_post.title.description, + target_keywords=blog_post.title.target_keywords or [], + ) + + result = run_agent_synchronously( + agent, + validation_context, + function_name="run_preliminary_validation", + ) + + validation_result = result.output + is_valid = validation_result.is_valid + + if is_valid: + blog_post.update_pipeline_step("preliminary_validation", "completed") + logger.info( + "[Pipeline] Preliminary validation passed", + blog_post_id=blog_post.id, + ) + else: + issues_text = "; ".join(validation_result.validation_issues) + + # Check if we've already tried to fix this multiple times + fix_attempt_count = ( + blog_post.pipeline_state.get("steps", {}) + .get("preliminary_validation", {}) + .get("fix_attempt_count", 0) + ) + + if fix_attempt_count >= 2: + # After 2 fix attempts, mark as failed to prevent infinite loop + blog_post.update_pipeline_step( + "preliminary_validation", + "failed", + error=f"Content validation failed after {fix_attempt_count} fix attempts - {issues_text}", # noqa: E501 + ) + logger.error( + "[Pipeline] Preliminary validation failed after multiple fix attempts", + blog_post_id=blog_post.id, + fix_attempt_count=fix_attempt_count, + validation_issues=validation_result.validation_issues, + ) + else: + blog_post.update_pipeline_step( + "preliminary_validation", + "needs_fix", + error=f"Content validation failed - {issues_text}", + ) + logger.warning( + "[Pipeline] Preliminary validation found issues", + blog_post_id=blog_post.id, + validation_issues=validation_result.validation_issues, + ) + + return { + "is_valid": is_valid, + "validation_issues": validation_result.validation_issues if not is_valid else [], + } + + except Exception as error: + blog_post.update_pipeline_step( + "preliminary_validation", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Preliminary validation error", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def fix_preliminary_validation(self, blog_post, validation_issues): + """ + Fix content issues identified during preliminary validation. + + Args: + blog_post: The GeneratedBlogPost object with content + validation_issues: List of validation issues to fix + + Returns: + Fixed content + """ + from core.agents.fix_validation_issue_agent import agent + from core.model_utils import run_agent_synchronously + from core.schemas import ContentFixContext + + blog_post.update_pipeline_step("fix_preliminary_validation", "in_progress") + + try: + context = ContentFixContext( + content=blog_post.content, + validation_issues=validation_issues, + ) + + result = run_agent_synchronously( + agent, + "Please fix the validation issues in this blog post content.", + deps=context, + function_name="fix_preliminary_validation", + ) + + fixed_content = result.output + + blog_post.content = fixed_content + blog_post.save(update_fields=["content"]) + + blog_post.update_pipeline_step("fix_preliminary_validation", "completed") + + # Reset the preliminary_validation step status to pending so it can be re-run + blog_post.update_pipeline_step("preliminary_validation", "pending") + + logger.info( + "[Pipeline] Fixed preliminary validation issues", + blog_post_id=blog_post.id, + ) + + return fixed_content + + except Exception as error: + blog_post.update_pipeline_step( + "fix_preliminary_validation", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Error fixing preliminary validation issues", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def insert_internal_links(self, blog_post): + """ + Step 4: Insert internal links into the content. + + Args: + blog_post: The GeneratedBlogPost object with validated content + + Returns: + Content with internal links inserted + """ + from core.agents.internal_links_agent import agent + from core.schemas import InternalLinkContext + + blog_post.update_pipeline_step("insert_internal_links", "in_progress") + + try: + project_pages = [ + ProjectPageContext( + url=page.url, + title=page.title, + description=page.description, + summary=page.summary, + always_use=page.always_use, + ) + for page in self.project.project_pages.filter(date_analyzed__isnull=False) + ] + + if not project_pages: + logger.info( + "[Pipeline] No project pages available for internal links, skipping", + blog_post_id=blog_post.id, + ) + blog_post.update_pipeline_step("insert_internal_links", "completed") + return blog_post.content + + deps = InternalLinkContext( + content=blog_post.content, + available_pages=project_pages, + ) + + result = run_agent_synchronously( + agent, + "Please insert relevant internal links into this content.", + deps=deps, + function_name="insert_internal_links", + model_name="BlogPostTitleSuggestion", + ) + + # Update blog post with content that has internal links + blog_post.content = result.output + blog_post.save(update_fields=["content"]) + + blog_post.update_pipeline_step("insert_internal_links", "completed") + + logger.info( + "[Pipeline] Internal links insertion completed", + blog_post_id=blog_post.id, + ) + + return result.output + + except Exception as error: + blog_post.update_pipeline_step( + "insert_internal_links", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Internal links insertion failed", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def run_final_validation(self, blog_post): + """ + Step 5: Run final validation on the complete content with internal links using AI. + + Args: + blog_post: The GeneratedBlogPost object with final content + + Returns: + bool: True if validation passed, False otherwise + """ + from core.agents.content_validation_agent import agent + from core.model_utils import run_agent_synchronously + from core.schemas import ContentValidationContext + + blog_post.update_pipeline_step("final_validation", "in_progress") + + try: + validation_context = ContentValidationContext( + content=blog_post.content, + title=blog_post.title.title, + description=blog_post.title.description, + target_keywords=blog_post.title.target_keywords or [], + ) + + result = run_agent_synchronously( + agent, + validation_context, + function_name="run_final_validation", + ) + + validation_result = result.output + is_valid = validation_result.is_valid + + if is_valid: + blog_post.update_pipeline_step("final_validation", "completed") + logger.info( + "[Pipeline] Final validation passed - blog post ready", + blog_post_id=blog_post.id, + ) + else: + issues_text = "; ".join(validation_result.validation_issues) + + # Check if we've already tried to fix this multiple times + fix_attempt_count = ( + blog_post.pipeline_state.get("steps", {}) + .get("final_validation", {}) + .get("fix_attempt_count", 0) + ) + + if fix_attempt_count >= 2: + # After 2 fix attempts, mark as failed to prevent infinite loop + blog_post.update_pipeline_step( + "final_validation", + "failed", + error=f"Content validation failed after {fix_attempt_count} fix attempts - {issues_text}", # noqa: E501 + ) + logger.error( + "[Pipeline] Final validation failed after multiple fix attempts", + blog_post_id=blog_post.id, + fix_attempt_count=fix_attempt_count, + validation_issues=validation_result.validation_issues, + ) + else: + blog_post.update_pipeline_step( + "final_validation", + "needs_fix", + error=f"Content validation failed - {issues_text}", + ) + logger.warning( + "[Pipeline] Final validation found issues", + blog_post_id=blog_post.id, + validation_issues=validation_result.validation_issues, + ) + + return { + "is_valid": is_valid, + "validation_issues": validation_result.validation_issues if not is_valid else [], + } + + except Exception as error: + blog_post.update_pipeline_step( + "final_validation", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Final validation error", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def fix_final_validation(self, blog_post, validation_issues): + """ + Fix content issues identified during final validation. + + Args: + blog_post: The GeneratedBlogPost object with content + validation_issues: List of validation issues to fix + + Returns: + Fixed content + """ + from core.agents.fix_validation_issue_agent import agent + from core.model_utils import run_agent_synchronously + from core.schemas import ContentFixContext + + blog_post.update_pipeline_step("fix_final_validation", "in_progress") + + try: + context = ContentFixContext( + content=blog_post.content, + validation_issues=validation_issues, + ) + + result = run_agent_synchronously( + agent, + "Please fix the validation issues in this blog post content.", + deps=context, + function_name="fix_final_validation", + ) + + fixed_content = result.output + + blog_post.content = fixed_content + blog_post.save(update_fields=["content"]) + + blog_post.update_pipeline_step("fix_final_validation", "completed") + + # Reset the final_validation step status to pending so it can be re-run + blog_post.update_pipeline_step("final_validation", "pending") + + logger.info( + "[Pipeline] Fixed final validation issues", + blog_post_id=blog_post.id, + ) + + return fixed_content + + except Exception as error: + blog_post.update_pipeline_step( + "fix_final_validation", + "failed", + error=str(error), + ) + logger.error( + "[Pipeline] Error fixing final validation issues", + blog_post_id=blog_post.id, + error=str(error), + exc_info=True, + ) + raise + + def execute_complete_pipeline(self): + """ + Execute the complete blog post generation pipeline sequentially. + + This method runs all steps without UI updates, suitable for scheduled tasks. + Returns the final GeneratedBlogPost or raises an exception on failure. + """ + logger.info( + "[Pipeline] Starting complete pipeline execution", + title_suggestion_id=self.id, + project_id=self.project_id, + title=self.title, ) - if self.project.enable_automatic_og_image_generation: - async_task( - "core.tasks.generate_og_image_for_blog_post", - blog_post.id, - group="Generate OG Image", + blog_post = self.start_generation_pipeline() + + try: + # Step 1: Generate structure + self.generate_structure(blog_post) + + # Step 2: Generate content + self.generate_content_from_structure(blog_post) + + # Step 3: Preliminary validation + validation_result = self.run_preliminary_validation(blog_post) + if not validation_result["is_valid"]: + logger.warning( + "[Pipeline] Preliminary validation failed, attempting to fix", + blog_post_id=blog_post.id, + ) + self.fix_preliminary_validation(blog_post, validation_result["validation_issues"]) + + # Step 4: Insert internal links + self.insert_internal_links(blog_post) + + # Step 5: Final validation + validation_result = self.run_final_validation(blog_post) + if not validation_result["is_valid"]: + logger.warning( + "[Pipeline] Final validation failed, attempting to fix", + blog_post_id=blog_post.id, + ) + self.fix_final_validation(blog_post, validation_result["validation_issues"]) + # Re-run final validation after fixing + validation_result = self.run_final_validation(blog_post) + if not validation_result["is_valid"]: + logger.error( + "[Pipeline] Final validation still failed after fix", + blog_post_id=blog_post.id, + ) + + # Generate OG image if enabled + if self.project.enable_automatic_og_image_generation: + async_task( + "core.tasks.generate_og_image_for_blog_post", + blog_post.id, + group="Generate OG Image", + ) + + logger.info( + "[Pipeline] Complete pipeline execution successful", + blog_post_id=blog_post.id, + title_suggestion_id=self.id, ) - return blog_post + return blog_post + + except Exception as error: + logger.error( + "[Pipeline] Complete pipeline execution failed", + blog_post_id=blog_post.id, + title_suggestion_id=self.id, + error=str(error), + exc_info=True, + ) + raise class AutoSubmissionSetting(BaseModel): @@ -1018,11 +1410,28 @@ class GeneratedBlogPost(BaseModel): posted = models.BooleanField(default=False) date_posted = models.DateTimeField(null=True, blank=True) - # Validation Issues - Innocent until proven guilty - content_too_short = models.BooleanField(default=False) - has_valid_ending = models.BooleanField(default=True) - placeholders = models.BooleanField(default=False) - starts_with_header = models.BooleanField(default=False) + # Pipeline fields + pipeline_state = models.JSONField( + null=True, + blank=True, + default=None, + help_text="Tracks the current step and status of the generation pipeline", + ) + generation_structure = models.TextField( + blank=True, + default="", + help_text="Stores the outline/structure from the first pipeline step", + ) + raw_content = models.TextField( + blank=True, + default="", + help_text="Stores content before internal links are added", + ) + pipeline_metadata = models.JSONField( + default=dict, + blank=True, + help_text="Stores timestamps, AI model info, and token usage per step", + ) objects = GeneratedBlogPostManager() @@ -1033,15 +1442,6 @@ def __str__(self): def post_title(self): return self.title.title - @property - def blog_post_content_is_valid(self): - return ( - self.content_too_short is False - and self.has_valid_ending is True - and self.placeholders is False - and self.starts_with_header is False - ) - @property def generated_blog_post_schema(self): return GeneratedBlogPostSchema( @@ -1051,59 +1451,20 @@ def generated_blog_post_schema(self): content=self.content, ) - def run_validation(self): - """Run validation and update fields in a single query.""" - from core.utils import ( - blog_post_has_placeholders, - blog_post_has_valid_ending, - blog_post_starts_with_header, - ) - - base_logger_info = { - "blog_post_id": self.id, - "project_id": self.project_id, - "project_name": self.project.name, - "profile_id": self.project.profile.id, - "profile_email": self.project.profile.user.email, - } - - logger.info("[Validation] Running validation", **base_logger_info) - + @property + def is_ready_to_view(self): + """Check if the blog post has passed final validation and is ready to view.""" if not self.content: - self.content_too_short = True - self.has_valid_ending = False - self.placeholders = False - self.starts_with_header = False - - else: - content = self.content.strip() - self.content_too_short = len(content) < 3000 - self.has_valid_ending = blog_post_has_valid_ending(self) - self.placeholders = blog_post_has_placeholders(self) - self.starts_with_header = blog_post_starts_with_header(self) - - self.save( - update_fields=[ - "content_too_short", - "has_valid_ending", - "placeholders", - "starts_with_header", - ] - ) - - logger.info( - "[Validation] Blog post validation complete", - **base_logger_info, - blog_post_title=self.title.title, - content_too_short=self.content_too_short, - has_valid_ending=self.has_valid_ending, - placeholders=self.placeholders, - starts_with_header=self.starts_with_header, + return False + if not self.pipeline_state: + return True + final_validation_status = ( + self.pipeline_state.get("steps", {}).get("final_validation", {}).get("status") ) + return final_validation_status == "completed" def _build_fix_context(self): """Build full context for content editor agent to ensure accurate regeneration.""" - from core.schemas import BlogPostGenerationContext, ProjectPageContext project_pages = [ ProjectPageContext( @@ -1128,30 +1489,6 @@ def _build_fix_context(self): project_keywords=project_keywords, ) - def fix_header_start(self): - self.refresh_from_db() - self.title.refresh_from_db() - - context = self._build_fix_context() - - result = run_agent_synchronously( - content_editor_agent, - """ - This blog post starts with a header (like # or ##) instead of regular text. - - Please remove it such that the content starts with regular text, usually an introduction. - """, # noqa: E501 - deps=context, - function_name="fix_header_start", - model_name="GeneratedBlogPost", - ) - - self.content = result.output - self.save(update_fields=["content"]) - self.run_validation() - - return True - def submit_blog_post_to_endpoint(self): from core.utils import replace_placeholders @@ -1203,83 +1540,141 @@ def submit_blog_post_to_endpoint(self): ) return False - def fix_content_length(self): - self.refresh_from_db() - self.title.refresh_from_db() - - context = self._build_fix_context() + def initialize_pipeline(self): + """Initialize the pipeline state for a new blog post generation.""" + initial_pipeline_state = { + "current_step": "generate_structure", + "steps": { + "generate_structure": {"status": "pending", "retry_count": 0, "error": None}, + "generate_content": {"status": "pending", "retry_count": 0, "error": None}, + "preliminary_validation": {"status": "pending", "retry_count": 0, "error": None}, + "fix_preliminary_validation": { + "status": "pending", + "retry_count": 0, + "error": None, + }, + "insert_internal_links": {"status": "pending", "retry_count": 0, "error": None}, + "final_validation": {"status": "pending", "retry_count": 0, "error": None}, + "fix_final_validation": {"status": "pending", "retry_count": 0, "error": None}, + }, + } + self.pipeline_state = initial_pipeline_state + self.pipeline_metadata = { + "started_at": timezone.now().isoformat(), + "steps_completed": 0, + "total_steps": 5, + } + self.save(update_fields=["pipeline_state", "pipeline_metadata"]) - result = run_agent_synchronously( - content_editor_agent, - """ - This blog post is too short. - I think something went wrong during generation. - Please regenerate. - """, - deps=context, - function_name="fix_content_length", - model_name="GeneratedBlogPost", + logger.info( + "[Pipeline] Initialized pipeline", + blog_post_id=self.id, + project_id=self.project_id, + project_name=self.project.name, ) - self.content = result.output - self.save(update_fields=["content"]) - self.run_validation() + def update_pipeline_step(self, step_name: str, status: str, error: str = None): + """Update the status of a specific pipeline step.""" + if not self.pipeline_state: + self.initialize_pipeline() - def fix_valid_ending(self): - self.refresh_from_db() - self.title.refresh_from_db() - - context = self._build_fix_context() + pipeline_state = self.pipeline_state + if step_name not in pipeline_state["steps"]: + logger.error( + "[Pipeline] Invalid step name", + step_name=step_name, + blog_post_id=self.id, + ) + return + + pipeline_state["steps"][step_name]["status"] = status + if error: + pipeline_state["steps"][step_name]["error"] = error + elif status == "pending": + # Clear error when resetting to pending + pipeline_state["steps"][step_name]["error"] = None + + if status == "completed": + pipeline_state["steps"][step_name]["completed_at"] = timezone.now().isoformat() + + # Only update steps_completed and current_step for main pipeline steps, not fix steps + if not step_name.startswith("fix_"): + self.pipeline_metadata["steps_completed"] = ( + self.pipeline_metadata.get("steps_completed", 0) + 1 + ) - result = run_agent_synchronously( - content_editor_agent, - """ - This blog post does not end on an ending that makes sense. - Most likely generation failed at some point and returned half completed content. - Please regenerate the blog post. - """, - deps=context, - function_name="fix_valid_ending", - model_name="GeneratedBlogPost", - ) + step_order = [ + "generate_structure", + "generate_content", + "preliminary_validation", + "insert_internal_links", + "final_validation", + ] + current_index = step_order.index(step_name) + if current_index < len(step_order) - 1: + pipeline_state["current_step"] = step_order[current_index + 1] + else: + pipeline_state["current_step"] = "completed" + self.pipeline_metadata["completed_at"] = timezone.now().isoformat() + + elif status == "failed": + pipeline_state["steps"][step_name]["retry_count"] = ( + pipeline_state["steps"][step_name].get("retry_count", 0) + 1 + ) + pipeline_state["steps"][step_name]["failed_at"] = timezone.now().isoformat() - self.content = result.output - self.save(update_fields=["content"]) - self.run_validation() + elif status == "needs_fix": + pipeline_state["steps"][step_name]["needs_fix_at"] = timezone.now().isoformat() + # Track how many times we've tried to fix this validation issue + pipeline_state["steps"][step_name]["fix_attempt_count"] = ( + pipeline_state["steps"][step_name].get("fix_attempt_count", 0) + 1 + ) - def fix_placeholders(self): - self.refresh_from_db() - self.title.refresh_from_db() + elif status == "in_progress": + pipeline_state["current_step"] = step_name + pipeline_state["steps"][step_name]["started_at"] = timezone.now().isoformat() - context = self._build_fix_context() + self.pipeline_state = pipeline_state + self.save(update_fields=["pipeline_state", "pipeline_metadata"]) - result = run_agent_synchronously( - content_editor_agent, - """ - The content contains placeholders. - Please regenerate the blog post without placeholders. - """, - deps=context, - function_name="fix_placeholders", - model_name="GeneratedBlogPost", + logger.info( + "[Pipeline] Updated step", + blog_post_id=self.id, + step_name=step_name, + status=status, + error=error, + retry_count=pipeline_state["steps"][step_name].get("retry_count", 0), ) - self.content = result.output - self.save(update_fields=["content"]) - self.run_validation() - - def fix_generated_blog_post(self): - if self.content_too_short is True: - self.fix_content_length() - - if self.has_valid_ending is False: - self.fix_valid_ending() + def get_pipeline_status(self): + """Return the current pipeline state for API consumption.""" + if not self.pipeline_state: + return { + "current_step": None, + "status": "not_started", + "steps": {}, + "progress_percentage": 0, + } + + steps_completed = self.pipeline_metadata.get("steps_completed", 0) + total_steps = self.pipeline_metadata.get("total_steps", 5) + progress_percentage = int((steps_completed / total_steps) * 100) + + return { + "current_step": self.pipeline_state.get("current_step"), + "status": self.pipeline_state.get("current_step", "pending"), + "steps": self.pipeline_state.get("steps", {}), + "progress_percentage": progress_percentage, + "metadata": self.pipeline_metadata, + } - if self.placeholders is True: - self.fix_placeholders() + def can_retry_step(self, step_name: str) -> bool: + """Check if a step can be retried (less than 3 attempts).""" + if not self.pipeline_state or step_name not in self.pipeline_state["steps"]: + return False - if self.starts_with_header is True: - self.fix_header_start() + retry_count = self.pipeline_state["steps"][step_name].get("retry_count", 0) + return retry_count < 3 class ProjectPage(BaseModel): @@ -1397,7 +1792,7 @@ def analyze_content(self): Analyze the page content using Claude via PydanticAI and update project details. Should be called after get_page_content(). """ - from core.agents import summarize_page_agent + from core.agents.summarize_page_agent import agent from core.utils import get_jina_embedding webpage_content = WebPageContent( @@ -1407,7 +1802,7 @@ def analyze_content(self): ) analysis_result = run_agent_synchronously( - summarize_page_agent, + agent, "Please analyze this web page.", deps=webpage_content, function_name="analyze_content", @@ -1724,8 +2119,8 @@ def generate_vs_blog_post(self): Generate comparison blog post content using Perplexity Sonar. This method uses Perplexity's web search capabilities to research both products. """ - from core.agents import competitor_vs_blog_post_agent - from core.schemas import CompetitorVsPostContext, ProjectPageContext + from core.agents.competitor_vs_blog_post_agent import agent + from core.schemas import CompetitorVsPostContext title = f"{self.project.name} vs. {self.name}: Which is Better?" @@ -1756,7 +2151,7 @@ def generate_vs_blog_post(self): prompt = "Write a comprehensive comparison blog post. Return ONLY the markdown content for the blog post, nothing else." # noqa: E501 result = run_agent_synchronously( - competitor_vs_blog_post_agent, + agent, prompt, deps=context, function_name="generate_vs_blog_post", diff --git a/core/prompts.py b/core/prompts.py deleted file mode 100644 index aa18349..0000000 --- a/core/prompts.py +++ /dev/null @@ -1,228 +0,0 @@ -TITLE_SUGGESTION_SYSTEM_PROMPTS = { - "SHARING": """ -You are Nicolas Cole, a renowned expert in creating viral online content that captivates readers' attention and drives sharing. Your approach has generated tens of millions of views and helped countless writers create content that spreads organically. - -Based on the web page content provided, generate 5 blog post titles and outlines that are optimized for virality and social sharing rather than SEO. Each title should follow these principles from "The Art and Business of Online Writing": - -1. Create an immediate emotional reaction (curiosity, surprise, or validation) -2. Promise a specific, valuable outcome the reader deeply desires -3. Use power words that trigger emotional responses (unforgettable, crucial, eye-opening, etc.) -4. Include numbers when appropriate to create clear expectations (preferably at the beginning) -5. Speak directly to the reader's identity or aspirations -6. Create a "curiosity gap" that can only be filled by reading the content -7. Answer all three critical questions: What is this about? Who is this for? What's the promise? -8. Remove unnecessary connecting words (if, when, does, it, too, for, etc.) - -Remember: The internet rewards content that moves FAST and delivers high "rate of revelation" - giving readers valuable insights quickly without wasting their time. Focus on creating content people will want to share because it makes THEM look good when they share it. - -Your titles should force readers to make a choice - either this is exactly what they need or it's not for them. Specificity is the secret to standing out in a crowded content landscape. The more specific you can be about why your content is exactly what your target readers are looking for, the more likely they are to engage with and share it. - -Avoid timely content in favor of timeless content that will remain relevant for years. The best performing content addresses universal human desires (success, recognition, belonging, mastery) through specific, actionable frameworks. -""", # noqa: E501 - "SEO": """ -You are an expert SEO content strategist and blog title generator. Your task is to create compelling, search-optimized blog post titles that will attract both readers and search engines over the long term. - -1. TIMELESS APPEAL: Create titles that will remain relevant for years, avoiding trendy phrases, years, or time-specific references unless absolutely necessary for the topic. - -2. SEARCH INTENT ALIGNMENT: Craft titles that clearly address one of these search intents: - - Informational (how-to, guides, explanations) - - Navigational (finding specific resources) - - Commercial (comparing options, reviews) - - Transactional (looking to take action) - -3. KEYWORD OPTIMIZATION: - - Include the primary keyword naturally, preferably near the beginning - - Incorporate relevant secondary keywords where appropriate - - Avoid keyword stuffing that makes titles sound unnatural - -4. TITLE STRUCTURE: - - Keep titles between 50-60 characters (approximately 10-12 words) - - Use power words that evoke emotion (essential, ultimate, proven, etc.) - - Consider using numbers in list-based titles (odd numbers often perform better) - - Use brackets or parentheses for clarification when helpful [Template], (Case Study) - -5. CLICK-WORTHINESS: - - Create a sense of value (comprehensive, definitive, etc.) - - Hint at solving a problem or fulfilling a need - - Avoid clickbait tactics that overpromise - - Maintain clarity - readers should know exactly what they'll get - -6. VARIETY OF FORMATS: - - How-to guides ("How to [Achieve Result] with [Method]") - - List posts ("X Ways to [Solve Problem]") - - Ultimate guides ("The Complete Guide to [Topic]") - - Question-based titles ("Why Does [Topic] Matter for [Audience]?") - - Problem-solution ("Struggling with [Problem]? Try These [Solutions]") - -For each title suggestion, provide a brief explanation (1-2 sentences) of why it would perform well from an SEO perspective. - -Here's information about my blog topic: -[I'll provide my blog topic, target audience, primary keywords, and any specific goals] -""", # noqa: E501 -} - -GENERATE_CONTENT_SYSTEM_PROMPTS = { - "SHARING": """ -## Content Creation Instructions - -Create viral, shareable content following Nicolas Cole's proven methodology from "The Art and Business of Online Writing." Your goal is to craft content that moves FAST, delivers high value quickly, and compels readers to share. - -### Understanding Your Category - -Before writing, identify: -- Which content bucket this falls into (General Audience, Niche Audience, or Industry Audience) -- Where this content sits on the Education-Entertainment spectrum -- Who your specific target reader is (be as specific as possible) - -### Headline Construction - -Create a headline that answers all three critical questions: -1. What is this about? -2. Who is this for? -3. What's the promise/outcome? - -Your headline should: -- Start with a number when possible (creates clear expectation) -- Place the most important words in the first 2-3 positions -- Remove unnecessary connecting words -- Include power words that trigger emotional responses -- Create a "curiosity gap" that can only be filled by reading - -### Content Structure - -Follow this proven structure: - -1. **Introduction** - - Start with an ultra-short first sentence (under 10 words) that captures the entire point - - Use the 1/3/1 paragraph structure: - * One strong opening sentence - * Three description sentences that clarify and amplify - * One conclusion sentence that transitions to your main points - - Answer immediately: What is this about? Is this for me? What are you promising? - -2. **Main Points** - - Break content into clearly defined sections with compelling subheadings - - For each main point: - * Start with a clear, specific statement - * Provide concrete examples or evidence - * Include a personal story that illustrates the point (using the Golden Intersection) - * End with practical application for the reader - -3. **Conclusion** - - Reinforce your original promise - - Provide a clear next step or call-to-action - - End with a thought-provoking statement that encourages sharing - -### Writing Style Optimization - -- **Optimize for "Rate of Revelation"** - * Remove anything that isn't absolutely necessary - * Use short paragraphs (1-3 sentences maximum) - * Make every sentence deliver value - * Use specific examples rather than general statements - -- **Use the Golden Intersection** - * When sharing personal experiences, always connect them directly to reader benefit - * Never make yourself the main character - make the reader the hero - * Use your experiences as context for the insights you're sharing - -- **Create Shareable Content** - * Address a specific pain point or desire - * Include at least one unexpected insight or perspective - * Focus on timeless value over timely information - * Create content readers will want to share to make themselves look good - -- **Enhance Credibility** - * Demonstrate Implied Credibility through quality of content - * Leverage Earned Credibility by referencing consistent work in this area - * Use Perceived Credibility sparingly and only when relevant - -### Final Polishing - -- Read through and remove any sentence that doesn't add immediate value -- Ensure every paragraph follows a rhythm (start with one sentence, build to 3-5, then back to one) -- Check that your content delivers on the specific promise made in the headline -- Verify your content is specific enough to force readers to make a choice (either this is exactly what they need or it's not for them) - -Remember: The most successful online writers aren't necessarily the most talented - they're the most consistent and the most specific. Your goal is to create content that delivers maximum value in minimum time. -""", # noqa: E501 - "SEO": """ -You are an expert SEO content writer with deep knowledge of search engine algorithms and user engagement metrics. Your task is to create comprehensive, valuable content that ranks well in search engines while genuinely serving the reader's needs. - -I'll provide a blog post title, and I need you to generate high-quality, SEO-optimized content following these guidelines: - -1. CONTENT STRUCTURE: - - Begin with a compelling introduction that includes the primary keyword and clearly states what the reader will learn - - Use H2 and H3 headings to organize content logically, incorporating relevant keywords naturally - - Include a clear conclusion that summarizes key points and provides next steps or a call-to-action - - Aim for comprehensive coverage with appropriate length (typically 1,200-2,000 words for most topics) - -2. SEO OPTIMIZATION: - - Naturally incorporate the primary keyword 3-5 times throughout the content (including once in the first 100 words) - - Use related secondary keywords and semantic variations to demonstrate topical authority - - Optimize meta description (150-160 characters) that includes the primary keyword and encourages clicks - - Create a URL slug that is concise and includes the primary keyword - -3. CONTENT QUALITY: - - Provide unique insights, not just information that can be found everywhere - - Include specific examples, case studies, or data points to support claims - - Answer the most important questions users have about this topic - - Address potential objections or concerns readers might have - -4. READABILITY: - - Write in a conversational, accessible tone appropriate for the target audience - - Use short paragraphs (2-3 sentences maximum) - - Include bulleted or numbered lists where appropriate - - Vary sentence structure to maintain reader interest - - Aim for a reading level appropriate to your audience (typically 7th-9th grade level) - -5. ENGAGEMENT ELEMENTS: - - Include 2-3 suggested places for relevant images, charts, or infographics with descriptive alt text - - Add internal linking opportunities to 3-5 related content pieces on your site - - Suggest 2-3 external authoritative sources to link to for supporting evidence - - Include questions throughout that prompt reader reflection - -6. E-E-A-T SIGNALS: - - Demonstrate Expertise through depth of information - - Show Experience by including practical applications or real-world examples - - Establish Authoritativeness by referencing industry standards or best practices - - Build Trustworthiness by presenting balanced information and citing sources - -7. USER INTENT SATISFACTION: - - Identify whether the search intent is informational, navigational, commercial, or transactional - - Ensure the content fully addresses that specific intent - - Provide clear next steps for the reader based on their likely stage in the buyer's journey -""", # noqa: E501 -} - -PRICING_PAGE_STRATEGY_SYSTEM_PROMPT = { - "Alex Hormozi": """ - You are Alex Hormozi, a successful entrepreneur, investor, and business growth strategist known for his expertise in scaling businesses and optimizing pricing models. His approach to SaaS pricing focuses on value-based pricing rather than cost-plus or competitor-based pricing. - - ## Core Pricing Principles - - 1. **Value Metric Alignment**: Price based on the specific value metric that matters most to customers (e.g., seats, transactions, revenue generated) - 2. **Grand Slam Offer (GSO) Framework**: Create irresistible offers by: - - Maximizing perceived value - - Minimizing risk through guarantees - - Reducing friction in the buying process - - Creating scarcity/urgency when appropriate - - 3. **Value-to-Price Gap**: Maintain a significant gap between the perceived value and the price charged (aim for 10x value perception) - 4. **Tiered Pricing Structure**: Typically recommends 3-tier pricing with: - - Low-entry option (to capture price-sensitive customers) - - Middle option (designed to be the most selected) - - Premium option (to anchor value and capture high-end customers) - 5. **Price Anchoring**: Use the premium tier to make middle-tier pricing seem more reasonable - - ## Implementation Tactics - - 1. **Value Articulation**: Clearly communicate ROI and outcomes, not just features - 2. **Performance-Based Components**: Consider including success-based pricing elements - 3. **Guarantee Structure**: Offer strong guarantees to reduce perceived risk - 4. **Expansion Revenue Focus**: Design pricing to naturally increase as customers derive more value - 5. **Testing Framework**: Continuously test pricing with new customers while grandfathering existing ones - - This approach emphasizes maximizing customer lifetime value through strategic pricing rather than competing on lowest price in the market. - """ # noqa: E501 -} diff --git a/core/schemas.py b/core/schemas.py index b4937fb..2345231 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -59,6 +59,9 @@ class ProjectDetails(BaseModel): So, if the business is local, please specify the country or region. Otherwise, use 'Global'. """ # noqa: E501 ) + is_on_free_plan: bool = Field( + default=False, description="Whether the project owner is on a free subscription plan" + ) @field_validator("type") @classmethod @@ -186,7 +189,7 @@ class BlogPostGenerationContext(BaseModel): title_suggestion: TitleSuggestion project_keywords: list[str] = [] project_pages: list[ProjectPageContext] = [] - content_type: str = Field(description="Type of content to generate (SEO or SHARING)") + content_type: str = Field(description="Type of content to generate (SEO)") class GeneratedBlogPostSchema(BaseModel): @@ -254,13 +257,6 @@ class CompetitorAnalysis(BaseModel): ) -class CompetitorVsTitleContext(BaseModel): - """Context for generating competitor comparison blog post titles.""" - - project_details: ProjectDetails - competitor_details: CompetitorDetails - - class CompetitorVsPostContext(BaseModel): """Context for generating competitor comparison blog post content.""" @@ -275,3 +271,79 @@ class CompetitorVsPostContext(BaseModel): project_pages: list[ProjectPageContext] = Field( default_factory=list, description="List of project pages available for linking" ) + + +class BlogPostSection(BaseModel): + """A single section in the blog post structure.""" + + heading: str = Field(description="H2 or H3 heading for this section") + level: int = Field(description="Heading level (2 for H2, 3 for H3)") + description: str = Field( + description="Brief description of what this section should cover (2-3 sentences)" + ) + target_word_count: int = Field( + description="Approximate number of words this section should contain" + ) + key_points: list[str] = Field( + description="List of 3-5 key points that should be covered in this section" + ) + + +class BlogPostStructure(BaseModel): + """Complete structure outline for a blog post.""" + + introduction_guidance: str = Field( + description="Guidance for writing the introduction (what to cover, tone, hook)" + ) + sections: list[BlogPostSection] = Field( + description="Ordered list of sections that make up the blog post body" + ) + conclusion_guidance: str = Field( + description="Guidance for writing the conclusion (key takeaways, CTA, final thoughts)" + ) + estimated_total_word_count: int = Field( + description="Estimated total word count for the entire blog post" + ) + seo_focus: list[str] = Field( + description="Primary keywords and topics to emphasize throughout the post" + ) + + +class InternalLinkContext(BaseModel): + """Context for inserting internal links into blog post content.""" + + content: str = Field(description="The blog post content in markdown format") + available_pages: list[ProjectPageContext] = Field( + description="List of project pages available for linking" + ) + + +class ContentValidationContext(BaseModel): + """Context for validating blog post content.""" + + content: str = Field(description="The blog post content to validate") + title: str = Field(description="The blog post title") + description: str = Field(description="The blog post description/summary") + target_keywords: list[str] = Field( + default_factory=list, + description="Target keywords the post should focus on", + ) + + +class ContentValidationResult(BaseModel): + """Result of content validation with validation status and reasons.""" + + is_valid: bool = Field(description="Whether the content is complete and ready for publication") + validation_issues: list[str] = Field( + default_factory=list, + description="List of specific issues found in the content that need to be fixed", + ) + + +class ContentFixContext(BaseModel): + """Context for fixing content validation issues.""" + + content: str = Field(description="The original blog post content that has validation issues") + validation_issues: list[str] = Field( + description="List of specific validation issues that need to be addressed" + ) diff --git a/core/tasks.py b/core/tasks.py index 7d5c957..070227e 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -1,5 +1,4 @@ import json -import random from urllib.parse import unquote import posthog @@ -8,7 +7,7 @@ from django.utils import timezone from django_q.tasks import async_task -from core.choices import ContentType, ProjectPageSource +from core.choices import ProjectPageSource from core.models import ( BlogPostTitleSuggestion, Competitor, @@ -327,8 +326,7 @@ def generate_blog_post_suggestions(project_id: int): if profile.reached_title_generation_limit: return "Title generation limit reached for free plan" - project.generate_title_suggestions(content_type=ContentType.SHARING, num_titles=3) - project.generate_title_suggestions(content_type=ContentType.SEO, num_titles=3) + project.generate_title_suggestions(num_titles=3) return "Blog post suggestions generated" @@ -474,9 +472,8 @@ def generate_and_post_blog_post(project_id: int): project_name=project.name, ) ungenerated_blog_post_suggestion = ungenerated_blog_post_suggestions.first() - blog_post_to_post = ungenerated_blog_post_suggestion.generate_content( - content_type=ungenerated_blog_post_suggestion.content_type - ) + # Use the new pipeline for generation + blog_post_to_post = ungenerated_blog_post_suggestion.execute_complete_pipeline() # if neither, create a new blog post title suggestion, generate the blog post if not blog_post_to_post: @@ -485,11 +482,9 @@ def generate_and_post_blog_post(project_id: int): project_id=project_id, project_name=project.name, ) - content_type = random.choice([choice[0] for choice in ContentType.choices]) - suggestions = project.generate_title_suggestions(content_type=content_type, num_titles=1) - blog_post_to_post = suggestions[0].generate_content( - content_type=suggestions[0].content_type - ) + suggestions = project.generate_title_suggestions(num_titles=1) + # Use the new pipeline for generation + blog_post_to_post = suggestions[0].execute_complete_pipeline() # once you have the generated blog post, submit it to the endpoint if blog_post_to_post: diff --git a/core/utils.py b/core/utils.py index b251fe1..5d1c17f 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,4 +1,3 @@ -import re from urllib.request import urlopen import posthog @@ -7,11 +6,8 @@ from django.conf import settings from django.core.files.base import ContentFile from django.forms.utils import ErrorList -from pydantic_ai import Agent -from core.choices import EmailType, KeywordDataSource, OGImageStyle, get_default_ai_model -from core.constants import PLACEHOLDER_BRACKET_PATTERNS, PLACEHOLDER_PATTERNS -from core.model_utils import run_agent_synchronously +from core.choices import EmailType, KeywordDataSource, OGImageStyle from core.models import EmailSent, GeneratedBlogPost, Keyword, Profile, Project, ProjectKeyword from tuxseo.utils import get_tuxseo_logger @@ -118,37 +114,6 @@ def save_keyword(keyword_text: str, project: Project): ProjectKeyword.objects.get_or_create(project=project, keyword=keyword_obj) -def blog_post_has_placeholders(blog_post: GeneratedBlogPost) -> bool: - content = blog_post.content or "" - content_lower = content.lower() - - for pattern in PLACEHOLDER_PATTERNS: - if pattern in content_lower: - logger.warning( - "[Blog Post Has Placeholders] Placeholder found", - pattern=pattern, - blog_post_id=blog_post.id, - ) - return True - - for pattern in PLACEHOLDER_BRACKET_PATTERNS: - matches = re.findall(pattern, content_lower) - if matches: - logger.warning( - "[Blog Post Has Placeholders] Bracket Placeholder found", - pattern=pattern, - blog_post_id=blog_post.id, - ) - return True - - logger.info( - "[Blog Post Has Placeholders] No placeholders found", - blog_post_id=blog_post.id, - ) - - return False - - def get_project_keywords_dict(project: Project) -> dict: """ Build a dictionary of project keywords for quick lookup. @@ -172,93 +137,6 @@ def get_project_keywords_dict(project: Project) -> dict: return project_keywords -def blog_post_has_valid_ending(blog_post: GeneratedBlogPost) -> bool: - content = blog_post.content - content = content.strip() - - agent = Agent( - get_default_ai_model(), - output_type=bool, - system_prompt=""" - You are an expert content editor analyzing blog post endings. Your task is to determine - whether the provided text represents a complete, proper conclusion to a blog post. - - A valid blog post ending should: - - Complete the final thought or sentence - - Provide closure to the topic being discussed - - Feel like a natural conclusion (not abruptly cut off) - - May include calls-to-action, summaries, or closing remarks - - An invalid ending would be: - - Cut off mid-sentence - - Ending abruptly without proper conclusion - - Incomplete thoughts or paragraphs - - Missing expected closing elements for the content type - - Analyze the text carefully and provide your assessment. Return True if the ending is valid, False if not. - """, # noqa: E501 - retries=2, - model_settings={"temperature": 0.1}, # Lower temperature for more consistent analysis - ) - - try: - result = run_agent_synchronously( - agent, - f"Please analyze this blog post and determine if it has a complete ending:\n\n{content}", # noqa: E501 - function_name="blog_post_has_valid_ending", - ) - - ending_is_valid = result.output - - if ending_is_valid: - logger.info( - "[Blog Post Has Valid Ending] Valid ending", - result=ending_is_valid, - blog_post_id=blog_post.id, - ) - else: - logger.warning( - "[Blog Post Has Valid Ending] Invalid ending", - result=ending_is_valid, - blog_post_id=blog_post.id, - ) - - return ending_is_valid - - except Exception as e: - logger.error( - "[Blog Post Has Valid Ending] AI analysis failed", - error=str(e), - exc_info=True, - content_length=len(content), - ) - return False - - -def blog_post_starts_with_header(blog_post: GeneratedBlogPost) -> bool: - content = blog_post.content or "" - content = content.strip() - - if not content: - return False - - header_or_asterisk_pattern = r"^(#{1,6}\s+|\*)" - starts_with_header_or_asterisk = bool(re.match(header_or_asterisk_pattern, content)) - - if starts_with_header_or_asterisk: - logger.warning( - "[Blog Post Starts With Header] Content starts with header or asterisk", - blog_post_id=blog_post.id, - ) - else: - logger.info( - "[Blog Post Starts With Header] Content starts with regular text", - blog_post_id=blog_post.id, - ) - - return starts_with_header_or_asterisk - - def track_email_sent(email_address: str, email_type: EmailType, profile: Profile = None): """ Track sent emails by creating EmailSent records. @@ -509,3 +387,62 @@ def get_jina_embedding(text: str) -> list[float] | None: exc_info=True, ) return None + + +def find_internal_link_opportunities( + content: str, project_pages: list, max_links: int = 10 +) -> list[dict]: + """ + Use embeddings to find relevant spots for internal links. + + Args: + content: The blog post content + project_pages: List of ProjectPage objects with embeddings + max_links: Maximum number of link opportunities to find + + Returns: + List of link opportunities with position and relevance info + """ + # This is a simplified version - in production, you would: + # 1. Split content into paragraphs + # 2. Get embeddings for each paragraph + # 3. Use cosine similarity to find relevant pages + # 4. Return top matches with position information + + opportunities = [] + + # For now, return empty list - actual implementation would use vector similarity + # This would be filled out when embeddings are ready + logger.info( + "[FindInternalLinkOpportunities] Finding link opportunities", + content_length=len(content), + available_pages=len(project_pages), + ) + + return opportunities + + +def insert_link_at_position(content: str, link_url: str, link_text: str, position: int) -> str: + """ + Smart link insertion at a specific position in the content. + + Args: + content: The original content + link_url: URL to link to + link_text: Anchor text for the link + position: Character position where to insert the link + + Returns: + Content with link inserted + """ + # Find the text to replace with a link + start_pos = max(0, position - len(link_text) // 2) + end_pos = min(len(content), position + len(link_text) // 2) + + # Create the markdown link + markdown_link = f"[{link_text}]({link_url})" + + # Insert the link + new_content = content[:start_pos] + markdown_link + content[end_pos:] + + return new_content diff --git a/frontend/src/controllers/blog_generation_pipeline_controller.js b/frontend/src/controllers/blog_generation_pipeline_controller.js new file mode 100644 index 0000000..8a24f10 --- /dev/null +++ b/frontend/src/controllers/blog_generation_pipeline_controller.js @@ -0,0 +1,304 @@ +import { Controller } from "@hotwired/stimulus"; +import { showMessage } from "../utils/messages"; + +export default class extends Controller { + static values = { + suggestionId: Number, + projectId: Number, + }; + + static targets = [ + "dialog", + "steps", + "progressBar", + "errorMessage", + "retryButton", + "closeButton", + ]; + + connect() { + this.blogPostId = null; + this.currentStep = null; + this.retryCount = {}; + this.maxRetries = 3; + + this.stepNames = [ + { key: "structure", label: "Generate Structure" }, + { key: "content", label: "Generate Content" }, + { key: "preliminary_validation", label: "Validate Content" }, + { key: "internal_links", label: "Insert Internal Links" }, + { key: "final_validation", label: "Final Validation" }, + ]; + } + + async startPipeline(event) { + event.preventDefault(); + + // Show dialog + if (this.hasDialogTarget) { + this.dialogTarget.classList.remove("hidden"); + } + + // Initialize UI + this.renderSteps(); + this.updateProgress(0); + + try { + // Start the pipeline + const response = await fetch(`/api/generate-blog-content-pipeline/${this.suggestionIdValue}/start`, { + method: "POST", + headers: { + "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value, + }, + }); + + const data = await response.json(); + + if (data.status === "error") { + throw new Error(data.message || "Failed to start pipeline"); + } + + this.blogPostId = data.blog_post_id; + + // Execute each step sequentially + for (let i = 0; i < this.stepNames.length; i++) { + const step = this.stepNames[i]; + this.currentStep = step.key; + + const success = await this.executeStep(step, i); + + if (!success) { + // Step failed, show retry option if available + break; + } + } + + // All steps completed successfully + this.onPipelineComplete(); + + } catch (error) { + this.showError(error.message || "Failed to start pipeline"); + } + } + + async executeStep(step, stepIndex) { + // Update UI to show step in progress + this.updateStepStatus(stepIndex, "in-progress"); + + try { + const response = await fetch( + `/api/generate-blog-content-pipeline/${this.blogPostId}/step/${step.key}`, + { + method: "POST", + headers: { + "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value, + }, + } + ); + + const data = await response.json(); + + if (data.status === "error") { + // Step failed + this.updateStepStatus(stepIndex, "failed"); + + // Check if we can retry + const retryCount = this.retryCount[step.key] || 0; + if (retryCount < this.maxRetries) { + this.showRetryOption(step, stepIndex); + return false; + } else { + this.showError(`Step "${step.label}" failed after ${this.maxRetries} attempts: ${data.message}`); + return false; + } + } + + // Step succeeded + this.updateStepStatus(stepIndex, "completed"); + this.updateProgress(((stepIndex + 1) / this.stepNames.length) * 100); + return true; + + } catch (error) { + this.updateStepStatus(stepIndex, "failed"); + this.showError(`Step "${step.label}" failed: ${error.message}`); + return false; + } + } + + async retryStep(event) { + event.preventDefault(); + + const stepKey = event.target.dataset.stepKey; + const stepIndex = event.target.dataset.stepIndex; + const step = this.stepNames[stepIndex]; + + // Increment retry count + this.retryCount[stepKey] = (this.retryCount[stepKey] || 0) + 1; + + // Hide retry button and error + this.hideError(); + + // Reset step status to pending + await fetch( + `/api/generate-blog-content-pipeline/${this.blogPostId}/retry/${stepKey}`, + { + method: "POST", + headers: { + "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value, + }, + } + ); + + // Execute the step again + const success = await this.executeStep(step, parseInt(stepIndex)); + + if (success) { + // Continue with remaining steps + for (let i = parseInt(stepIndex) + 1; i < this.stepNames.length; i++) { + const nextStep = this.stepNames[i]; + const nextSuccess = await this.executeStep(nextStep, i); + + if (!nextSuccess) { + break; + } + } + + // Check if all steps completed + if (parseInt(stepIndex) === this.stepNames.length - 1) { + this.onPipelineComplete(); + } + } + } + + renderSteps() { + if (!this.hasStepsTarget) return; + + this.stepsTarget.innerHTML = this.stepNames.map((step, index) => ` +
+
+ + + +
+
+
+ ${step.label} +
+
+ Pending +
+
+
+ `).join(""); + } + + updateStepStatus(stepIndex, status) { + const iconElement = this.stepsTarget.querySelector(`[data-step-icon="${stepIndex}"]`); + const statusElement = this.stepsTarget.querySelector(`[data-step-status="${stepIndex}"]`); + + if (!iconElement || !statusElement) return; + + // Update icon + if (status === "in-progress") { + iconElement.innerHTML = ` + + + + + `; + iconElement.className = "flex items-center justify-center flex-shrink-0 w-8 h-8 border-2 border-blue-600 rounded-full"; + statusElement.textContent = "In Progress..."; + statusElement.className = "text-xs text-blue-600"; + } else if (status === "completed") { + iconElement.innerHTML = ` + + + + `; + iconElement.className = "flex items-center justify-center flex-shrink-0 w-8 h-8 border-2 border-green-600 rounded-full bg-green-50"; + statusElement.textContent = "Completed"; + statusElement.className = "text-xs text-green-600"; + } else if (status === "failed") { + iconElement.innerHTML = ` + + + + `; + iconElement.className = "flex items-center justify-center flex-shrink-0 w-8 h-8 border-2 border-red-600 rounded-full bg-red-50"; + statusElement.textContent = "Failed"; + statusElement.className = "text-xs text-red-600"; + } + } + + updateProgress(percentage) { + if (!this.hasProgressBarTarget) return; + + this.progressBarTarget.style.width = `${percentage}%`; + this.progressBarTarget.setAttribute("aria-valuenow", percentage); + } + + showError(message) { + if (this.hasErrorMessageTarget) { + this.errorMessageTarget.textContent = message; + this.errorMessageTarget.classList.remove("hidden"); + } + } + + hideError() { + if (this.hasErrorMessageTarget) { + this.errorMessageTarget.classList.add("hidden"); + } + } + + showRetryOption(step, stepIndex) { + const retryCount = this.retryCount[step.key] || 0; + const remainingRetries = this.maxRetries - retryCount; + + if (this.hasRetryButtonTarget) { + this.retryButtonTarget.innerHTML = ` + + `; + this.retryButtonTarget.classList.remove("hidden"); + } + } + + onPipelineComplete() { + if (this.hasCloseButtonTarget) { + this.closeButtonTarget.classList.remove("hidden"); + this.closeButtonTarget.innerHTML = ` + + + + + + View Generated Post + + `; + } + + showMessage("Blog post generated successfully!", "success"); + this.updateProgress(100); + } + + closeDialog(event) { + event.preventDefault(); + + if (this.hasDialogTarget) { + this.dialogTarget.classList.add("hidden"); + } + + // Reload the page to show the new blog post + window.location.reload(); + } +} diff --git a/frontend/src/controllers/content_idea_controller.js b/frontend/src/controllers/content_idea_controller.js index eee6549..6c3c609 100644 --- a/frontend/src/controllers/content_idea_controller.js +++ b/frontend/src/controllers/content_idea_controller.js @@ -12,9 +12,8 @@ export default class extends Controller { } getCurrentTab() { - // Find the active tab button - const activeTab = document.querySelector('[data-action="title-suggestions#switchTab"].text-gray-900'); - return activeTab ? activeTab.dataset.tab : "SHARING"; // Default to SHARING if no tab is active + // Always return SEO + return "SEO"; } async generate() { diff --git a/frontend/src/controllers/fix-blog-post-controller.js b/frontend/src/controllers/fix-blog-post-controller.js deleted file mode 100644 index d9419eb..0000000 --- a/frontend/src/controllers/fix-blog-post-controller.js +++ /dev/null @@ -1,72 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static values = { generatedPostId: Number }; - static targets = ["button", "buttonText", "buttonIcon"]; - - connect() { - this.originalButtonText = this.buttonTextTarget.textContent; - this.originalButtonIcon = this.buttonIconTarget.innerHTML; - } - - async fix() { - // Disable button and show loading state - this.buttonTarget.disabled = true; - this.buttonTextTarget.textContent = "Fixing..."; - this.buttonIconTarget.innerHTML = ` - - - - - `; - - try { - const response = await fetch("/api/fix-generated-blog-post", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - body: JSON.stringify({ - id: this.generatedPostIdValue - }) - }); - - const data = await response.json(); - - if (data.status === "success") { - // Reload the page on success - window.location.reload(); - } else { - // Show error message - alert(data.message || "Failed to fix blog post"); - this.resetButton(); - } - } catch (error) { - console.error("Error fixing blog post:", error); - alert("An error occurred while fixing the blog post"); - this.resetButton(); - } - } - - resetButton() { - this.buttonTarget.disabled = false; - this.buttonTextTarget.textContent = this.originalButtonText; - this.buttonIconTarget.innerHTML = this.originalButtonIcon; - } - - getCSRFToken() { - const token = document.querySelector("[name=\"csrfmiddlewaretoken\"]")?.value; - if (token) return token; - - // Fallback: try to get from cookie - const cookies = document.cookie.split(";"); - for (let cookie of cookies) { - const [name, value] = cookie.trim().split("="); - if (name === "csrftoken") { - return value; - } - } - return ""; - } -} diff --git a/frontend/src/controllers/generate-content-controller.js b/frontend/src/controllers/generate-content-controller.js index d64cf2d..0cd2849 100644 --- a/frontend/src/controllers/generate-content-controller.js +++ b/frontend/src/controllers/generate-content-controller.js @@ -5,6 +5,7 @@ export default class extends Controller { url: String, suggestionId: Number, projectId: Number, + blogPostId: Number, hasProSubscription: Boolean, hasAutoSubmissionSetting: Boolean, pricingUrl: String, @@ -53,39 +54,47 @@ export default class extends Controller { `; - // Update status to show generating state - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = ` -
-
- Generating... -
- `; - } + // Show progress dialog + this._showProgressDialog(); - const response = await fetch(`/api/generate-blog-content/${this.suggestionIdValue}`, { + // Start the pipeline + const startResponse = await fetch(`/api/generate-blog-content-pipeline/${this.suggestionIdValue}/start`, { method: "POST", headers: { - "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value + "X-CSRFToken": this._getCsrfToken() } }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || "Generation failed"); + if (!startResponse.ok) { + const error = await startResponse.json(); + throw new Error(error.message || "Failed to start generation"); } - const data = await response.json(); + const startData = await startResponse.json(); + this.blogPostId = startData.blog_post_id; + this.pipelineSteps = ["structure", "content", "preliminary_validation", "internal_links", "final_validation"]; + + // Execute each step of the pipeline + for (const step of this.pipelineSteps) { + const stepResult = await this._executeStep(step); - // Check if the response indicates an error - if (data.status === "error") { - throw new Error(data.message || "Generation failed"); + // Check if validation failed and needs fixing + if (stepResult && stepResult.needs_fix) { + const fixStepName = `fix_${step}`; + await this._executeStep(fixStepName); + + // Re-run validation after fixing + await this._executeStep(step); + } } + // Hide progress dialog + this._hideProgressDialog(); + // Update button to "View Post" this.buttonContainerTarget.innerHTML = ` @@ -106,10 +115,14 @@ export default class extends Controller { } // Handle the post button - this._appendPostButton(this.postButtonContainerTarget, data.id); + this._appendPostButton(this.postButtonContainerTarget, this.blogPostId); + + showMessage("Blog post generated successfully!", 'success'); } catch (error) { + this._hideProgressDialog(); showMessage(error.message || "Failed to generate content. Please try again later.", 'error'); + // Reset the button to original state this.buttonContainerTarget.innerHTML = ` + `; + + // Show progress dialog + this._showProgressDialog(); + + // Get current pipeline status + const statusResponse = await fetch(`/api/generate-blog-content-pipeline/${this.blogPostId}/status`, { + method: "GET", + headers: { + "X-CSRFToken": this._getCsrfToken() + } + }); + + if (!statusResponse.ok) { + throw new Error("Failed to get pipeline status"); + } + + const statusData = await statusResponse.json(); + + // Map backend step names to frontend step names + const step_name_map = { + "generate_structure": "structure", + "generate_content": "content", + "preliminary_validation": "preliminary_validation", + "insert_internal_links": "internal_links", + "final_validation": "final_validation" + }; + + // Update progress dialog with current state + if (statusData.steps) { + for (const [backend_step_name, step_info] of Object.entries(statusData.steps)) { + const frontend_step_name = step_name_map[backend_step_name]; + if (frontend_step_name && step_info.status === "completed") { + this._updateProgressStep(frontend_step_name, "completed", this._getStepDisplayName(frontend_step_name)); + } + } + } + + // Determine which steps need to be executed + this.pipelineSteps = ["structure", "content", "preliminary_validation", "internal_links", "final_validation"]; + const steps_to_execute = []; + + for (const frontend_step of this.pipelineSteps) { + const backend_step = this._getBackendStepName(frontend_step); + const step_status = statusData.steps?.[backend_step]; + + // Check if step needs to be executed or fixed + if (!step_status || step_status.status !== "completed") { + // If validation step has "needs_fix" status, trigger fix step instead + if (step_status && step_status.status === "needs_fix" && + (frontend_step === "preliminary_validation" || frontend_step === "final_validation")) { + steps_to_execute.push(`fix_${frontend_step}`); + steps_to_execute.push(frontend_step); // Re-run validation after fix + } else { + steps_to_execute.push(frontend_step); + } + } + } + + // Execute remaining steps + for (const step of steps_to_execute) { + const stepResult = await this._executeStep(step); + + // Check if validation failed and needs fixing (for new validation failures) + if (stepResult && stepResult.needs_fix) { + const fixStepName = `fix_${step}`; + await this._executeStep(fixStepName); + + // Re-run validation after fixing + const revalidationResult = await this._executeStep(step); + + // If validation still needs fixing after one fix attempt, stop here + // User will need to click Continue again to retry + if (revalidationResult && revalidationResult.needs_fix) { + throw new Error("Validation still has issues after fix. Please review the content and try again."); + } + } + } + + // Hide progress dialog + this._hideProgressDialog(); + + // Update button to "View Post" + this.buttonContainerTarget.innerHTML = ` + + + + + + View Post + + `; + + // Update status to show completed state + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ` +
+
+ Generated +
+ `; + } + + // Handle the post button + this._appendPostButton(this.postButtonContainerTarget, this.blogPostId); + + showMessage("Blog post generation completed!", 'success'); + + } catch (error) { + this._hideProgressDialog(); + showMessage(error.message || "Failed to continue generation. Please try again later.", 'error'); + + // Reset the button to Continue state + this.buttonContainerTarget.innerHTML = ` + + `; + } + } + + async _executeStep(step_name) { + const step_display_names = { + "structure": "Generating Structure", + "content": "Generating Content", + "preliminary_validation": "Validating Content", + "fix_preliminary_validation": "Fixing Validation Issues", + "internal_links": "Adding Internal Links", + "final_validation": "Final Validation", + "fix_final_validation": "Fixing Validation Issues" + }; + + // Map frontend step names to backend internal step names + const backend_step_names = { + "structure": "generate_structure", + "content": "generate_content", + "preliminary_validation": "preliminary_validation", + "fix_preliminary_validation": "fix_preliminary_validation", + "internal_links": "insert_internal_links", + "final_validation": "final_validation", + "fix_final_validation": "fix_final_validation" + }; + + // Update progress dialog to show current step + this._updateProgressStep(step_name, "in_progress", step_display_names[step_name]); + + try { + const response = await fetch(`/api/generate-blog-content-pipeline/${this.blogPostId}/step/${step_name}`, { + method: "POST", + headers: { + "X-CSRFToken": this._getCsrfToken() + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `Failed to execute ${step_name}`); + } + + const data = await response.json(); + + // Check if validation failed but can be fixed + if (data.needs_fix) { + // Don't mark as completed - it needs fixing + // Just return the data so the caller can trigger the fix step + return data; + } + + // Get the backend step name to check status + const backend_step_name = backend_step_names[step_name]; + const step_status = data.pipeline_state?.steps?.[backend_step_name]; + + // Check if step failed and needs retry + if (step_status && step_status.status === "failed") { + this._updateProgressStep(step_name, "failed", `${step_display_names[step_name]} Failed`); + throw new Error(step_status.error || `Step ${step_name} failed`); + } + + // Update progress dialog to show step completed + this._updateProgressStep(step_name, "completed", step_display_names[step_name]); + + // Return the result data for validation checks + return data; + + } catch (error) { + this._updateProgressStep(step_name, "failed", step_display_names[step_name]); + throw error; + } + } + + _showProgressDialog() { + const dialog = document.createElement("div"); + dialog.id = "generation-progress-dialog"; + dialog.className = "flex fixed inset-0 z-50 justify-center items-center bg-black bg-opacity-50"; + dialog.innerHTML = ` +
+

Generating Blog Post

+
+
+
+ Generating Structure +
+
+
+ Generating Content +
+
+
+ Validating Content +
+
+
+ Adding Internal Links +
+
+
+ Final Validation +
+
+
+ `; + document.body.appendChild(dialog); + } + + _hideProgressDialog() { + const dialog = document.getElementById("generation-progress-dialog"); + if (dialog) { + dialog.remove(); + } + } + + _updateProgressStep(step_name, status, display_text) { + let step_element = document.querySelector(`[data-step="${step_name}"]`); + + // If step doesn't exist in dialog, create it dynamically (for fix steps) + if (!step_element && step_name.startsWith("fix_")) { + const progress_steps_container = document.getElementById("progress-steps"); + if (progress_steps_container) { + // Determine the position to insert the fix step + const parent_step_name = step_name.replace("fix_", ""); + const parent_step_element = document.querySelector(`[data-step="${parent_step_name}"]`); + + if (parent_step_element) { + // Create new step element + const new_step = document.createElement("div"); + new_step.setAttribute("data-step", step_name); + new_step.className = "flex gap-3 items-center ml-6"; + new_step.innerHTML = ` +
+ ${display_text} + `; + + // Insert after the parent step + parent_step_element.insertAdjacentElement("afterend", new_step); + step_element = new_step; + } + } + } + + if (!step_element) return; + + const icon = step_element.querySelector(".step-icon"); + const text = step_element.querySelector(".step-text"); + + if (status === "in_progress") { + icon.innerHTML = ` + + + + + `; + text.className = "text-sm font-medium text-blue-600 step-text"; + } else if (status === "completed") { + icon.innerHTML = ` + + + + `; + text.className = "text-sm text-gray-600 line-through step-text"; + } else if (status === "failed") { + icon.innerHTML = ` + + + + `; + text.className = "text-sm font-medium text-red-600 step-text"; + } + + text.textContent = display_text; + } + + _getCsrfToken() { + return document.querySelector("[name=csrfmiddlewaretoken]").value; + } + + _getStepDisplayName(step_name) { + const display_names = { + "structure": "Generating Structure", + "content": "Generating Content", + "preliminary_validation": "Validating Content", + "fix_preliminary_validation": "Fixing Validation Issues", + "internal_links": "Adding Internal Links", + "final_validation": "Final Validation", + "fix_final_validation": "Fixing Validation Issues" + }; + return display_names[step_name] || step_name; + } + + _getBackendStepName(frontend_step_name) { + const backend_names = { + "structure": "generate_structure", + "content": "generate_content", + "preliminary_validation": "preliminary_validation", + "fix_preliminary_validation": "fix_preliminary_validation", + "internal_links": "insert_internal_links", + "final_validation": "final_validation", + "fix_final_validation": "fix_final_validation" + }; + return backend_names[frontend_step_name] || frontend_step_name; + } + _appendPostButton(container, generatedPostId) { container.innerHTML = ''; diff --git a/frontend/src/controllers/scan_progress_controller.js b/frontend/src/controllers/scan_progress_controller.js index cd36d71..ef9cfd4 100644 --- a/frontend/src/controllers/scan_progress_controller.js +++ b/frontend/src/controllers/scan_progress_controller.js @@ -84,7 +84,7 @@ export default class extends Controller { return scanData; } - async generateSuggestions(projectId, contentType="SHARING") { + async generateSuggestions(projectId, contentType="SEO") { const suggestionsResponse = await fetch('/api/generate-title-suggestions', { method: 'POST', headers: { diff --git a/frontend/src/controllers/title_suggestions_controller.js b/frontend/src/controllers/title_suggestions_controller.js index 90d1bf6..2cfb171 100644 --- a/frontend/src/controllers/title_suggestions_controller.js +++ b/frontend/src/controllers/title_suggestions_controller.js @@ -10,56 +10,10 @@ export default class extends Controller { static targets = ["suggestionsList", "suggestionsContainer", "activeSuggestionsList"]; connect() { - // Get the last selected tab from localStorage, default to "SHARING" if none exists - this.currentTabValue = localStorage.getItem("selectedTab") || "SHARING"; - - // Update initial tab UI - const tabs = this.element.querySelectorAll("[data-action='title-suggestions#switchTab']"); - tabs.forEach(t => { - if (t.dataset.tab === this.currentTabValue) { - t.classList.add("text-gray-900", "border-b-2", "border-gray-900"); - t.classList.remove("text-gray-500", "hover:text-gray-700", "border-transparent", "hover:border-gray-300"); - } else { - t.classList.remove("text-gray-900", "border-b-2", "border-gray-900"); - t.classList.add("text-gray-500", "hover:text-gray-700", "border-b-2", "border-transparent", "hover:border-gray-300"); - } - }); - - // Filter suggestions based on initial tab - this.filterSuggestions(); + // Always use SEO content type + this.currentTabValue = "SEO"; } - switchTab(event) { - const selectedTab = event.currentTarget.dataset.tab; - this.currentTabValue = selectedTab; - localStorage.setItem("selectedTab", selectedTab); - - // Update tab UI - const tabs = this.element.querySelectorAll("[data-action='title-suggestions#switchTab']"); - tabs.forEach(t => { - if (t.dataset.tab === selectedTab) { - t.classList.add("text-gray-900", "border-b-2", "border-gray-900"); - t.classList.remove("text-gray-500", "hover:text-gray-700", "border-transparent", "hover:border-gray-300"); - } else { - t.classList.remove("text-gray-900", "border-b-2", "border-gray-900"); - t.classList.add("text-gray-500", "hover:text-gray-700", "border-b-2", "border-transparent", "hover:border-gray-300"); - } - }); - - this.filterSuggestions(); - } - - filterSuggestions() { - const suggestions = this.suggestionsListTarget.querySelectorAll("[data-suggestion-type]"); - - suggestions.forEach(suggestion => { - if (suggestion.dataset.suggestionType === this.currentTabValue) { - suggestion.classList.remove("hidden"); - } else { - suggestion.classList.add("hidden"); - } - }); - } diff --git a/frontend/templates/blog/generated_blog_post_detail.html b/frontend/templates/blog/generated_blog_post_detail.html index 357a48e..694ede0 100644 --- a/frontend/templates/blog/generated_blog_post_detail.html +++ b/frontend/templates/blog/generated_blog_post_detail.html @@ -32,9 +32,6 @@

{% endif %} - - {% include "components/blog_post_validation_warning.html" with generated_post=generated_post %} -
@@ -75,24 +87,17 @@

{% with generated_post=suggestion.generated_blog_posts.first %} - {% if generated_post and generated_post.content %} - - {% if generated_post.blog_post_content_is_valid %} -
-
- Ready to post -
- {% else %} - - {% endif %} + {% if generated_post and generated_post.is_ready_to_view %} +
+
+ Ready to post +
{% elif generated_post %} -
+ {# In-progress generation or validation failed #} +
+
+ In Progress +
{% else %}
diff --git a/frontend/templates/components/blog_post_validation_warning.html b/frontend/templates/components/blog_post_validation_warning.html deleted file mode 100644 index b3c5a4c..0000000 --- a/frontend/templates/components/blog_post_validation_warning.html +++ /dev/null @@ -1,47 +0,0 @@ - -{% if not generated_post.blog_post_content_is_valid %} -
-
-
- - - -
-
-

- Content Quality Issues Detected -

-
-

This blog post has some quality issues that should be addressed before posting:

-
    - {% if generated_post.content_too_short %} -
  • Content is too short (less than 3,000 characters). Likely that generation failed at some point.
  • - {% endif %} - {% if not generated_post.has_valid_ending %} -
  • Content does not have a proper ending. Likely that generation failed at some point.
  • - {% endif %} - {% if generated_post.placeholders %} -
  • Content contains placeholder text that needs to be replaced
  • - {% endif %} -
-
-
- -
-
-
-
-{% endif %} diff --git a/frontend/templates/project/project_detail.html b/frontend/templates/project/project_detail.html index 184d266..fcc882c 100644 --- a/frontend/templates/project/project_detail.html +++ b/frontend/templates/project/project_detail.html @@ -137,26 +137,6 @@

Content Generator

Your Blog Post Ideas

- -
- -
@@ -182,26 +162,6 @@

Active Ideas

- -
-
- - -
-
-