From 05f190b9704278d3e4806202952837158e7a4413 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 06:49:27 +0000 Subject: [PATCH] feat: Add inspiration feature for blog posts This commit introduces a new feature that allows users to add and manage inspiration sources for their blog posts. This includes: - A new `Inspiration` model to store inspiration data. - API endpoints for adding and deleting inspirations. - Frontend components and controllers for managing inspirations. - Integration with the agent system to use inspirations for content generation. Co-authored-by: me --- core/admin.py | 2 + core/agent_system_prompts.py | 35 ++++ core/agents.py | 2 + core/api/schemas.py | 22 +++ core/api/views.py | 86 +++++++++ core/models.py | 142 ++++++++++++++- core/schemas.py | 14 ++ core/urls.py | 5 + core/views.py | 20 ++ .../src/controllers/inspiration_controller.js | 135 ++++++++++++++ frontend/templates/base_project.html | 11 ++ .../project/project_inspirations.html | 171 ++++++++++++++++++ 12 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 frontend/src/controllers/inspiration_controller.js create mode 100644 frontend/templates/project/project_inspirations.html diff --git a/core/admin.py b/core/admin.py index 35658da..15cebe4 100644 --- a/core/admin.py +++ b/core/admin.py @@ -6,6 +6,7 @@ BlogPostTitleSuggestion, EmailSent, GeneratedBlogPost, + Inspiration, Profile, Project, ProjectPage, @@ -80,3 +81,4 @@ class ReferrerBannerAdmin(admin.ModelAdmin): admin.site.register(AutoSubmissionSetting) admin.site.register(ProjectPage) admin.site.register(EmailSent) +admin.site.register(Inspiration) diff --git a/core/agent_system_prompts.py b/core/agent_system_prompts.py index d64e7e6..27faeba 100644 --- a/core/agent_system_prompts.py +++ b/core/agent_system_prompts.py @@ -153,6 +153,41 @@ def add_target_keywords(ctx: RunContext[BlogPostGenerationContext]) -> str: return "" +def add_inspirations(ctx: RunContext[BlogPostGenerationContext]) -> str: + if not ctx.deps.inspirations: + return "" + + inspiration_sections = [] + inspiration_sections.append( + """ + INSPIRATION SOURCES: + The user has shared the following blog posts and blogs as inspiration sources + they would like to emulate in style, tone, and structure. + Use these as reference points when generating content: + """ + ) + + for inspiration in ctx.deps.inspirations: + inspiration_sections.append( + f""" + - Title: {inspiration.title} + URL: {inspiration.url} + Description: {inspiration.description or 'No description provided'} + """ + ) + + inspiration_sections.append( + """ + IMPORTANT: Draw inspiration from these sources' writing style, tone, structure, + and approach while creating content that is unique and appropriate for the current project. + Consider how these inspirations structure their content, engage their audience, + and present information. + """ + ) + + return "\n".join(inspiration_sections) + + def add_webpage_content(ctx: RunContext[WebPageContent]) -> str: return ( "Web page content:" diff --git a/core/agents.py b/core/agents.py index e7f0030..550185a 100644 --- a/core/agents.py +++ b/core/agents.py @@ -4,6 +4,7 @@ from pydantic_ai.providers.openai import OpenAIProvider from core.agent_system_prompts import ( + add_inspirations, add_language_specification, add_project_details, add_project_pages, @@ -51,6 +52,7 @@ def only_return_the_edited_content() -> str: content_editor_agent.system_prompt(add_title_details) content_editor_agent.system_prompt(add_language_specification) content_editor_agent.system_prompt(add_target_keywords) +content_editor_agent.system_prompt(add_inspirations) ######################################################## diff --git a/core/api/schemas.py b/core/api/schemas.py index 844253d..a0e0f9e 100644 --- a/core/api/schemas.py +++ b/core/api/schemas.py @@ -262,3 +262,25 @@ class ToggleProjectPageAlwaysUseOut(Schema): status: str always_use: bool message: str | None = None + + +class AddInspirationIn(Schema): + project_id: int + url: str + + +class AddInspirationOut(Schema): + status: str + message: str | None = None + inspiration_id: int | None = None + title: str | None = None + url: str | None = None + + +class DeleteInspirationIn(Schema): + inspiration_id: int + + +class DeleteInspirationOut(Schema): + status: str + message: str | None = None diff --git a/core/api/views.py b/core/api/views.py index 9c10266..813dac8 100644 --- a/core/api/views.py +++ b/core/api/views.py @@ -9,12 +9,16 @@ from core.api.auth import session_auth, superuser_api_auth from core.api.schemas import ( AddCompetitorIn, + AddInspirationIn, + AddInspirationOut, AddKeywordIn, AddKeywordOut, AddPricingPageIn, BlogPostIn, BlogPostOut, CompetitorAnalysisOut, + DeleteInspirationIn, + DeleteInspirationOut, DeleteProjectKeywordIn, DeleteProjectKeywordOut, FixGeneratedBlogPostIn, @@ -52,6 +56,7 @@ Competitor, Feedback, GeneratedBlogPost, + Inspiration, Keyword, Project, ProjectKeyword, @@ -1056,3 +1061,84 @@ def toggle_project_page_always_use(request: HttpRequest, data: ToggleProjectPage "always_use": False, "message": f"Failed to toggle always use: {str(error)}", } + + +@api.post("/inspirations/add", response=AddInspirationOut, auth=[session_auth]) +def add_inspiration(request: HttpRequest, data: AddInspirationIn): + """Add a new inspiration link to a project.""" + profile = request.auth + project = get_object_or_404(Project, id=data.project_id, profile=profile) + + url_to_add = data.url.strip() + + if not url_to_add: + return {"status": "error", "message": "URL cannot be empty"} + + if not url_to_add.startswith(("http://", "https://")): + return {"status": "error", "message": "URL must start with http:// or https://"} + + try: + if Inspiration.objects.filter(project=project, url=url_to_add).exists(): + return {"status": "error", "message": "This inspiration already exists for your project"} + + inspiration = Inspiration.objects.create(project=project, url=url_to_add) + + inspiration.get_page_content() + + logger.info( + "[Add Inspiration] Successfully added inspiration", + inspiration_id=inspiration.id, + project_id=project.id, + url=url_to_add, + ) + + return { + "status": "success", + "inspiration_id": inspiration.id, + "title": inspiration.title, + "url": inspiration.url, + "message": "Inspiration added successfully!", + } + + except Exception as e: + logger.error( + "Failed to add inspiration", + error=str(e), + exc_info=True, + project_id=project.id, + url=url_to_add, + ) + return {"status": "error", "message": f"An unexpected error occurred: {str(e)}"} + + +@api.post("/inspirations/delete", response=DeleteInspirationOut, auth=[session_auth]) +def delete_inspiration(request: HttpRequest, data: DeleteInspirationIn): + """Delete an inspiration from a project.""" + profile = request.auth + + try: + inspiration = get_object_or_404( + Inspiration, id=data.inspiration_id, project__profile=profile + ) + + inspiration_url = inspiration.url + inspiration.delete() + + logger.info( + "[Delete Inspiration] Successfully deleted inspiration", + inspiration_id=data.inspiration_id, + url=inspiration_url, + profile_id=profile.id, + ) + + return {"status": "success", "message": f"Inspiration deleted successfully"} + + except Exception as e: + logger.error( + "Failed to delete inspiration", + error=str(e), + exc_info=True, + inspiration_id=data.inspiration_id, + profile_id=profile.id, + ) + return {"status": "error", "message": f"Failed to delete inspiration: {str(e)}"} diff --git a/core/models.py b/core/models.py index d456971..ca544f0 100644 --- a/core/models.py +++ b/core/models.py @@ -631,6 +631,49 @@ def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: return "\n".join(feedback_sections) + @agent.system_prompt + def add_inspirations(ctx: RunContext[TitleSuggestionContext]) -> str: + if not ctx.deps.inspirations: + return "" + + inspiration_sections = [] + inspiration_sections.append( + """ + IMPORTANT: The user has shared the following blog posts and blogs as inspiration + sources they would like to emulate in style, tone, and structure. + Use these as reference points when generating titles: + """ + ) + + for inspiration in ctx.deps.inspirations: + inspiration_sections.append( + f""" + - Title: {inspiration.title} + URL: {inspiration.url} + Description: {inspiration.description or 'No description provided'} + """ + ) + + inspiration_sections.append( + """ + Draw inspiration from these sources' writing style, tone, and structure + while creating titles that are unique and appropriate for the current project. + """ + ) + + return "\n".join(inspiration_sections) + + from core.schemas import InspirationContext + + inspirations = [ + InspirationContext( + url=insp.url, + title=insp.title or insp.url, + description=insp.description or "", + ) + for insp in self.inspirations.all() + ] + deps = TitleSuggestionContext( project_details=self.project_details, num_titles=num_titles, @@ -640,6 +683,7 @@ def add_feedback_history(ctx: RunContext[TitleSuggestionContext]) -> str: suggestion.title for suggestion in self.disliked_title_suggestions ], neutral_suggestions=[suggestion.title for suggestion in self.neutral_title_suggestions], + inspirations=inspirations, ) result = run_agent_synchronously( @@ -874,6 +918,8 @@ def title_suggestion_schema(self): ) def generate_content(self, content_type=ContentType.SHARING, model=None): + from core.agent_system_prompts import add_inspirations + agent = Agent( model or get_default_ai_model(), output_type=GeneratedBlogPostSchema, @@ -889,6 +935,7 @@ def generate_content(self, content_type=ContentType.SHARING, model=None): agent.system_prompt(add_todays_date) agent.system_prompt(add_language_specification) agent.system_prompt(add_target_keywords) + agent.system_prompt(add_inspirations) agent.system_prompt(valid_markdown_format) agent.system_prompt(markdown_lists) agent.system_prompt(post_structure) @@ -911,12 +958,24 @@ def generate_content(self, content_type=ContentType.SHARING, model=None): for pk in self.project.project_keywords.filter(use=True).select_related("keyword") ] + from core.schemas import InspirationContext + + inspirations = [ + InspirationContext( + url=insp.url, + title=insp.title or insp.url, + description=insp.description or "", + ) + for insp in self.project.inspirations.all() + ] + 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, + inspirations=inspirations, ) result = run_agent_synchronously( @@ -1085,7 +1144,7 @@ def run_validation(self): def _build_fix_context(self): """Build full context for content editor agent to ensure accurate regeneration.""" - from core.schemas import BlogPostGenerationContext, ProjectPageContext + from core.schemas import BlogPostGenerationContext, InspirationContext, ProjectPageContext project_pages = [ ProjectPageContext( @@ -1102,12 +1161,22 @@ def _build_fix_context(self): for pk in self.project.project_keywords.filter(use=True).select_related("keyword") ] + inspirations = [ + InspirationContext( + url=insp.url, + title=insp.title or insp.url, + description=insp.description or "", + ) + for insp in self.project.inspirations.all() + ] + return BlogPostGenerationContext( project_details=self.project.project_details, title_suggestion=self.title.title_suggestion_schema, project_pages=project_pages, content_type=self.title.content_type, project_keywords=project_keywords, + inspirations=inspirations, ) def fix_header_start(self): @@ -1963,3 +2032,74 @@ class Meta: def __str__(self): return f"{self.email_type} to {self.email_address}" + + +class Inspiration(BaseModel): + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="inspirations", + help_text="The project this inspiration belongs to", + ) + url = models.URLField( + max_length=500, + help_text="URL to the blog post or blog that inspires the user", + ) + title = models.CharField( + max_length=500, + blank=True, + default="", + help_text="Title of the inspiration (auto-fetched or user-provided)", + ) + description = models.TextField( + blank=True, + default="", + help_text="Description or notes about why this is inspiring", + ) + date_scraped = models.DateTimeField( + null=True, + blank=True, + help_text="When the content was scraped from the URL", + ) + markdown_content = models.TextField( + blank=True, + default="", + help_text="Markdown content scraped from the URL", + ) + + class Meta: + verbose_name = "Inspiration" + verbose_name_plural = "Inspirations" + ordering = ["-created_at"] + unique_together = ("project", "url") + + def __str__(self): + return f"{self.project.name}: {self.title or self.url}" + + def get_page_content(self): + """Fetch page content using Jina Reader API and update the inspiration.""" + title, description, markdown_content = get_markdown_content(self.url) + + if not markdown_content: + logger.error( + "[Get Page Content] Failed to get inspiration content", + url=self.url, + project_id=self.project_id, + ) + return False + + self.date_scraped = timezone.now() + self.title = title or self.title + self.description = description or self.description + self.markdown_content = markdown_content + + self.save( + update_fields=[ + "date_scraped", + "title", + "description", + "markdown_content", + ] + ) + + return True diff --git a/core/schemas.py b/core/schemas.py index dfb64f3..f7c6ed6 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -127,6 +127,14 @@ def validate_type(cls, v): return v +class InspirationContext(BaseModel): + """Context for an inspiration source.""" + + url: str = Field(description="URL of the inspiration") + title: str = Field(description="Title of the inspiration") + description: str = Field(description="Description of the inspiration") + + class TitleSuggestionContext(BaseModel): """Context for generating blog post title suggestions.""" @@ -144,6 +152,9 @@ class TitleSuggestionContext(BaseModel): disliked_suggestions: list[str] | None = Field( default_factory=list, description="Titles the user has previously disliked" ) + inspirations: list[InspirationContext] = Field( + default_factory=list, description="Inspiration sources the user wants to emulate" + ) class TitleSuggestion(BaseModel): @@ -185,6 +196,9 @@ class BlogPostGenerationContext(BaseModel): project_keywords: list[str] = [] project_pages: list[ProjectPageContext] = [] content_type: str = Field(description="Type of content to generate (SEO or SHARING)") + inspirations: list[InspirationContext] = Field( + default_factory=list, description="Inspiration sources the user wants to emulate" + ) class GeneratedBlogPostSchema(BaseModel): diff --git a/core/urls.py b/core/urls.py index a71a2da..e4f0cd5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -29,6 +29,11 @@ views.ProjectCompetitorsView.as_view(), name="project_competitors", ), + path( + "project//inspirations/", + views.ProjectInspirationsView.as_view(), + name="project_inspirations", + ), path( "project//publish-history", views.PublishHistoryView.as_view(), diff --git a/core/views.py b/core/views.py index 6cfab5e..9ed2950 100644 --- a/core/views.py +++ b/core/views.py @@ -27,6 +27,7 @@ BlogPost, Competitor, GeneratedBlogPost, + Inspiration, KeywordTrend, Profile, ProfileStateTransition, @@ -862,6 +863,25 @@ def get_context_data(self, **kwargs): return context +class ProjectInspirationsView(LoginRequiredMixin, DetailView): + model = Project + template_name = "project/project_inspirations.html" + context_object_name = "project" + + def get_queryset(self): + return Project.objects.filter(profile=self.request.user.profile) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + project = self.object + + inspirations = project.inspirations.order_by("-created_at") + + context["inspirations"] = inspirations + + return context + + class GeneratedBlogPostDetailView(LoginRequiredMixin, DetailView): model = GeneratedBlogPost template_name = "blog/generated_blog_post_detail.html" diff --git a/frontend/src/controllers/inspiration_controller.js b/frontend/src/controllers/inspiration_controller.js new file mode 100644 index 0000000..84fc11b --- /dev/null +++ b/frontend/src/controllers/inspiration_controller.js @@ -0,0 +1,135 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["form", "urlInput", "submitButton", "message", "tableBody", "emptyState"]; + static values = { projectId: Number }; + + toggleForm() { + this.formTarget.classList.toggle("hidden"); + if (!this.formTarget.classList.contains("hidden")) { + this.urlInputTarget.focus(); + this.clearMessage(); + } else { + this.urlInputTarget.value = ""; + this.clearMessage(); + } + } + + async addInspiration() { + const url = this.urlInputTarget.value.trim(); + + if (!url) { + this.showMessage("Please enter a URL", "error"); + return; + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + this.showMessage("URL must start with http:// or https://", "error"); + return; + } + + this.submitButtonTarget.disabled = true; + this.submitButtonTarget.innerHTML = ` + + + + + Adding... + `; + + try { + const response = await fetch("/api/inspirations/add", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCsrfToken(), + }, + body: JSON.stringify({ + project_id: this.projectIdValue, + url: url, + }), + }); + + const data = await response.json(); + + if (data.status === "success") { + this.showMessage(data.message, "success"); + this.urlInputTarget.value = ""; + + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + this.showMessage(data.message || "Failed to add inspiration", "error"); + } + } catch (error) { + console.error("Error adding inspiration:", error); + this.showMessage("An error occurred. Please try again.", "error"); + } finally { + this.submitButtonTarget.disabled = false; + this.submitButtonTarget.innerHTML = ` + + + + Add Inspiration + `; + } + } + + async deleteInspiration(event) { + const inspirationId = event.currentTarget.dataset.inspirationId; + + if (!confirm("Are you sure you want to delete this inspiration?")) { + return; + } + + try { + const response = await fetch("/api/inspirations/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCsrfToken(), + }, + body: JSON.stringify({ + inspiration_id: parseInt(inspirationId), + }), + }); + + const data = await response.json(); + + if (data.status === "success") { + window.location.reload(); + } else { + alert(data.message || "Failed to delete inspiration"); + } + } catch (error) { + console.error("Error deleting inspiration:", error); + alert("An error occurred. Please try again."); + } + } + + showMessage(text, type) { + const messageEl = this.messageTarget; + messageEl.classList.remove("hidden"); + + if (type === "success") { + messageEl.className = "mt-4 p-4 bg-green-50 border border-green-200 rounded-md text-sm text-green-800"; + } else { + messageEl.className = "mt-4 p-4 bg-red-50 border border-red-200 rounded-md text-sm text-red-800"; + } + + messageEl.textContent = text; + } + + clearMessage() { + if (this.hasMessageTarget) { + this.messageTarget.classList.add("hidden"); + this.messageTarget.textContent = ""; + } + } + + getCsrfToken() { + return document.querySelector("[name=csrfmiddlewaretoken]")?.value || + document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ""; + } +} diff --git a/frontend/templates/base_project.html b/frontend/templates/base_project.html index 83f2ccc..2e082c6 100644 --- a/frontend/templates/base_project.html +++ b/frontend/templates/base_project.html @@ -115,6 +115,17 @@

+ + + + Inspirations + +
  • TuxSEO - {{ project.name }} - Inspirations + +{% endblock meta %} + +{% block project_content %} +
    + +
    +

    Inspirations

    +

    + Share links to blog posts or blogs that inspire you and would like to emulate in your content +

    +
    + + +
    + +
    + + + + + +
    + {% if inspirations %} +
    + + + + + + + + + + + {% for inspiration in inspirations %} + + + + + + + {% endfor %} + +
    + Title + + URL + + Added + + Actions +
    +
    + {{ inspiration.title|default:"Loading..." }} +
    + {% if inspiration.description %} +
    + {{ inspiration.description|truncatewords:20 }} +
    + {% endif %} +
    + + {{ inspiration.url|truncatechars:50 }} + + + + + + {{ inspiration.created_at|date:"M d, Y" }} + + +
    +
    + {% else %} +
    + + + +

    No inspirations yet

    +

    + Get started by adding your first inspiration source. +

    +
    + +
    +
    + {% endif %} +
    + + + {% if inspirations %} +
    +

    + Total Inspirations: {{ inspirations.count }} +

    +
    + {% endif %} + +
    +{% endblock project_content %}