From b5607f89ff7629a4abda1b3cf408021535b4c0dc Mon Sep 17 00:00:00 2001 From: Alexander Kadyrov Date: Sun, 24 Nov 2024 02:56:11 +0400 Subject: [PATCH] Generate a content for social media campaigns Resolves #180 --- prompts/00-social-media-campaigns.txt | 42 ++++ .../[id]/social_media_campaigns/route.ts | 25 ++ src/app/ideas/[id]/IdeaAnalysisReport.tsx | 1 + .../[id]/components/SectionContentIdeas.tsx | 30 +++ .../SocialMediaCampaigns.tsx | 224 +++++++++++++++++ .../components/NavBar.tsx | 26 ++ .../components/SectionLongFormContent.tsx | 98 ++++++++ .../components/SectionShortFormContent.tsx | 93 +++++++ .../components/SectionVideoContent.tsx | 91 +++++++ .../[id]/social_media_campaigns/page.tsx | 26 ++ src/idea/adapters/IdeaRepositorySQLite.ts | 83 +++++++ .../SocialMediaCampaignsEvaluator.ts | 233 ++++++++++++++++++ src/idea/app/App.ts | 4 + .../commands/RequestSocialMediaCampaigns.ts | 49 ++++ .../app/queries/GetSocialMediaCampaigns.ts | 86 +++++++ src/idea/domain/Aggregate.ts | 12 + src/idea/domain/Repository.ts | 4 + src/idea/domain/SocialMediaCampaigns.ts | 61 +++++ .../events/SocialMediaCampaignsRequested.ts | 12 + .../SocialMediaCampaignsSubscriber.ts | 178 +++++++++++++ src/idea/service/Service.ts | 20 ++ 21 files changed, 1398 insertions(+) create mode 100644 prompts/00-social-media-campaigns.txt create mode 100644 src/app/api/ideas/[id]/social_media_campaigns/route.ts create mode 100644 src/app/ideas/[id]/social_media_campaigns/SocialMediaCampaigns.tsx create mode 100644 src/app/ideas/[id]/social_media_campaigns/components/NavBar.tsx create mode 100644 src/app/ideas/[id]/social_media_campaigns/components/SectionLongFormContent.tsx create mode 100644 src/app/ideas/[id]/social_media_campaigns/components/SectionShortFormContent.tsx create mode 100644 src/app/ideas/[id]/social_media_campaigns/components/SectionVideoContent.tsx create mode 100644 src/app/ideas/[id]/social_media_campaigns/page.tsx create mode 100644 src/idea/adapters/OpenAIService/SocialMediaCampaignsEvaluator.ts create mode 100644 src/idea/app/commands/RequestSocialMediaCampaigns.ts create mode 100644 src/idea/app/queries/GetSocialMediaCampaigns.ts create mode 100644 src/idea/domain/SocialMediaCampaigns.ts create mode 100644 src/idea/domain/events/SocialMediaCampaignsRequested.ts create mode 100644 src/idea/events/subscribers/SocialMediaCampaignsSubscriber.ts diff --git a/prompts/00-social-media-campaigns.txt b/prompts/00-social-media-campaigns.txt new file mode 100644 index 0000000..81d5d8f --- /dev/null +++ b/prompts/00-social-media-campaigns.txt @@ -0,0 +1,42 @@ +You are a content creation expert. + +Based on the following product description, target audience, value proposition, and social media campaign ideas, generate both short-form and long-form content suitable for various platforms. + +Provide the content along with tips on how to adapt it for Twitter, LinkedIn, Facebook, Instagram, and general use. + +For each piece of content, also provide a short image prompt that can be used to generate an accompanying image. The image prompt should be concise, descriptive, and suitable for the content and platform. + +### Content Expectations + +The **platform** field in your response must be in lowercase and must be one of the requested platforms only, without any extra social media platforms. +The **tips** field must have 3-5 items. + +1. Short-Form Content + +- Platforms: twitter, linkedin, facebook, instagram, general post. +- Content must be short, consisting of one paragraph (3-4 sentences). + +2. Long-Form Content + +- Platforms: linkedin, facebook, medium, blog post. +- Content must be concise, consisting of 2-3 paragraphs. + +3. Video Content + +- Platforms: youtube, instagram, youtube shorts. +- Script should contain up to 10 items or bullet points. + +### Guidelines + +- **Provide the Content Itself**: Generate the content following the length and format guidelines above. +- **Authentic and Conversational Tone**: Write the content in a natural, human-like manner that feels genuine and relatable to the reader. +- **Language**: Skip formal, sales-like language, and buzzwords such as: streamline, enhance, tailor, leverage, thrill, seamless, etc., in any form. +- **Provide Unique Insights**: Include specific examples, anecdotes, or lesser-known tips related to the product idea that offer real value and set the content apart from generic material. +- **Avoid Generic Statements**: Steer clear of vague or overused phrases; make the content specific to the product and audience. +- **Clarity and Conciseness**: Ensure the content is clear, concise, and to the point, avoiding unnecessary fluff or filler content. +- **Engaging and Interactive**: Incorporate elements that encourage reader engagement, such as questions, calls-to-action, or prompts for comments and shares. +- **Include a Header for Each Piece**: For each piece of content, include a header that explains what is inside and why it matters, using simple and friendly language without buzzwords or marketing jargon. +- **Specify the Platform**: Clearly indicate the platform the content is intended for. +- **Proofread and Polish**: Provide content that is free from grammatical errors and flows logically, enhancing readability. +- **Include Platform-Specific Tips**: Include tips on how to adapt or optimize the content for the specified platform. +- **Include Image Prompt**: For each piece of content, provide a short image prompt that describes an image suitable to accompany the content. The image prompt should be concise, vivid, and appropriate for the platform and audience. diff --git a/src/app/api/ideas/[id]/social_media_campaigns/route.ts b/src/app/api/ideas/[id]/social_media_campaigns/route.ts new file mode 100644 index 0000000..477b786 --- /dev/null +++ b/src/app/api/ideas/[id]/social_media_campaigns/route.ts @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/nextjs' +import { NextResponse } from 'next/server' +import { App } from '@/idea/service/Service' + +export async function POST(_: Request, { params }: { params: { id: string } }) { + try { + await App.Commands.RequestSocialMediaCampaigns.handle({ + ideaId: params.id, + }) + + return NextResponse.json( + { success: true, message: 'Social media campaigns have been requested' }, + { status: 200 } + ) + } catch (error) { + console.error('Error while requesting social media campaigns:', error) + + Sentry.captureException(error) + + return NextResponse.json( + { error: 'Error while requesting social media campaigns.' }, + { status: 500 } + ) + } +} diff --git a/src/app/ideas/[id]/IdeaAnalysisReport.tsx b/src/app/ideas/[id]/IdeaAnalysisReport.tsx index ca495d4..e536f87 100644 --- a/src/app/ideas/[id]/IdeaAnalysisReport.tsx +++ b/src/app/ideas/[id]/IdeaAnalysisReport.tsx @@ -330,6 +330,7 @@ export const IdeaAnalysisReport = ({ data }: Props) => { diff --git a/src/app/ideas/[id]/components/SectionContentIdeas.tsx b/src/app/ideas/[id]/components/SectionContentIdeas.tsx index ef7b9ae..3b5a381 100644 --- a/src/app/ideas/[id]/components/SectionContentIdeas.tsx +++ b/src/app/ideas/[id]/components/SectionContentIdeas.tsx @@ -1,5 +1,6 @@ 'use client' +import Link from 'next/link' import React, { useState } from 'react' import FetchingDataMessage from '@/components/FetchingDataMessage' import Paragraph from '@/components/Paragraph' @@ -15,18 +16,22 @@ interface ContentIdeaProps { } interface SectionContentIdeasProps { + ideaId: string onReport: (section: string) => void data: Record | null } interface ContentIdeaSectionProps { + ideaId: string onReport: (section: string) => void section: string header: string + downloadableContent?: boolean data: ContentIdeaProps } const SectionContentIdeas: React.FC = ({ + ideaId, onReport, data, }) => { @@ -54,13 +59,16 @@ const SectionContentIdeas: React.FC = ({ {data ? ( <> = ({ /> = ({ /> = ({ /> = ({ /> = ({ /> = ({ /> = ({ /> = ({ /> = ({ } const ContentIdea: React.FC = ({ + ideaId, onReport, section, header, data, + downloadableContent = false, }) => (
= ({ ))} + + {downloadableContent && ( +
+ + Generate Content + +
+ )}
) diff --git a/src/app/ideas/[id]/social_media_campaigns/SocialMediaCampaigns.tsx b/src/app/ideas/[id]/social_media_campaigns/SocialMediaCampaigns.tsx new file mode 100644 index 0000000..4775355 --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/SocialMediaCampaigns.tsx @@ -0,0 +1,224 @@ +'use client' + +import { useRouter } from 'next/navigation' +import React, { useEffect, useState } from 'react' +import BackToTopButton from '@/components/BackToTopButton' +import FeedbackForm from '@/components/FeedbackForm' +import FetchingDataMessage from '@/components/FetchingDataMessage' +import HorizontalLine from '@/components/HorizontalLine' +import { NavBar } from './components/NavBar' +import SectionLongFormContent from './components/SectionLongFormContent' +import SectionShortFormContent from './components/SectionShortFormContent' +import SectionVideoContent from './components/SectionVideoContent' + +interface Props { + data: { + id: string + contents: { + shortFormContent: Array<{ + header: string + platform: string + content: string + tips: string[] + imagePrompt: string + }> + longFormContent: Array<{ + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string + }> + videoContent: Array<{ + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string + }> + } | null + } +} + +const reloadInterval = 5000 + +export const SocialMediaCampaigns = ({ data }: Props) => { + const router = useRouter() + + const [showFeedbackForm, setShowFeedbackForm] = useState(false) + const [wrongSection, setWrongSection] = useState(null) + + const [status, setStatus] = useState('idle') + + const onReport = (section: string) => { + if (!section) { + return + } + + setWrongSection(section) + + setShowFeedbackForm(true) + } + + const handleFeedbackSubmit = async (feedback: string, contact: string) => { + if (!wrongSection) { + return + } + + try { + setWrongSection(null) + setShowFeedbackForm(false) + + const res = await fetch(`/api/ideas/${data.id}/feedback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + section: wrongSection, + feedback: feedback.trim(), + contact: contact.trim(), + }), + }) + + if (res.status === 201) { + setWrongSection(null) + setShowFeedbackForm(false) + } else { + const errorData = await res.json() + + alert(errorData.error || 'Something went wrong.') + + setWrongSection(null) + setShowFeedbackForm(false) + } + } catch (error) { + alert(`Error submitting report: ${error}`) + } + } + + const handleRequest = async () => { + setStatus('loading') + + try { + const res = await fetch(`/api/ideas/${data.id}/social_media_campaigns`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (res.status === 200) { + // Do nothing. The page will be reloaded automatically + } else { + setStatus('error') + const errorData = await res.json() + + alert(errorData.error || 'Something went wrong.') + } + } catch (error) { + setStatus('error') + alert(`Error requesting social media campaigns: ${error}`) + } + } + + useEffect(() => { + let intervalId: NodeJS.Timeout + + if (status === 'loading') { + if (data.contents === null) { + intervalId = setInterval(() => { + router.refresh() + }, reloadInterval) + } else { + setStatus('ready') + } + } + + return () => { + clearInterval(intervalId) + } + }, [status, router, data.contents]) + + return ( +
+
+ + +
+
+

+ Social Media Campaigns +

+ + {data.contents === null && ( + <> + {status === 'idle' ? ( + + ) : ( + + Loading... + + )} + + )} +
+ + + + {showFeedbackForm && ( +
+
+ setShowFeedbackForm(false)} + /> +
+
+ )} + + {data.contents ? ( + <> + + + + + + + ) : ( + <> + {status === 'idle' ? ( +

+ Please click "Request" button above to fetch your + content. +

+ ) : ( + + )} + + )} +
+
+ + +
+ ) +} diff --git a/src/app/ideas/[id]/social_media_campaigns/components/NavBar.tsx b/src/app/ideas/[id]/social_media_campaigns/components/NavBar.tsx new file mode 100644 index 0000000..2a93478 --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/components/NavBar.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link' +import React from 'react' +import HorizontalLine from '@/components/HorizontalLine' + +const className = + 'block rounded px-4 py-2 text-gray-900 dark:text-gray-200 dark:hover:bg-gray-700 hover:bg-gray-200' + +export const NavBar = ({ ideaId }: { ideaId: string }) => ( + +) diff --git a/src/app/ideas/[id]/social_media_campaigns/components/SectionLongFormContent.tsx b/src/app/ideas/[id]/social_media_campaigns/components/SectionLongFormContent.tsx new file mode 100644 index 0000000..069d2bc --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/components/SectionLongFormContent.tsx @@ -0,0 +1,98 @@ +'use client' + +import React, { useState } from 'react' +import Paragraph from '@/components/Paragraph' +import Section from '@/components/Section' +import SectionDescription from '@/components/SectionDescription' +import SectionHeader from '@/components/SectionHeader' +import SectionWrapper from '@/components/SectionWrapper' +import SimpleUnorderedList from '@/components/SimpleUnorderedList' + +interface SectionLongFormContentProps { + onReport: (section: string) => void + data: Array<{ + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string + }> +} + +const SectionLongFormContent: React.FC = ({ + onReport, + data, +}) => { + const [isExpanded, setIsExpanded] = useState(true) + + return ( + + setIsExpanded(!isExpanded)} + isExpanded={isExpanded} + sectionId="section_long_form_content" + > + Long-Form Content + + + {isExpanded && ( +
+ + This section offers you more detailed content ideas like blog posts + or articles. Long-form content lets you explore topics in depth, + giving valuable information to your readers. It's a great way + to share your knowledge, tell stories, and connect with your + audience on a deeper level. + + + {data.map((content, idx) => ( +
+ onReport( + `social_media_campaigns.long_form_content.${content.platform}` + ) + } + > +
+

+ Title: +

+ {content.title} + +

+ Content: +

+ + + {content.content.split('\n').map((line, index) => ( + + {line} +
+
+ ))} +
+ +

+ Adaptation Tips: +

+ + + +

+ Image Prompt for AI: +

+ + {content.imagePrompt} +
+
+ ))} +
+ )} +
+ ) +} + +export default SectionLongFormContent diff --git a/src/app/ideas/[id]/social_media_campaigns/components/SectionShortFormContent.tsx b/src/app/ideas/[id]/social_media_campaigns/components/SectionShortFormContent.tsx new file mode 100644 index 0000000..41eacd6 --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/components/SectionShortFormContent.tsx @@ -0,0 +1,93 @@ +'use client' + +import React, { useState } from 'react' +import Paragraph from '@/components/Paragraph' +import Section from '@/components/Section' +import SectionDescription from '@/components/SectionDescription' +import SectionHeader from '@/components/SectionHeader' +import SectionWrapper from '@/components/SectionWrapper' +import SimpleUnorderedList from '@/components/SimpleUnorderedList' + +interface SectionShortFormContentProps { + onReport: (section: string) => void + data: Array<{ + header: string + platform: string + content: string + tips: string[] + imagePrompt: string + }> +} + +const SectionShortFormContent: React.FC = ({ + onReport, + data, +}) => { + const [isExpanded, setIsExpanded] = useState(true) + + return ( + + setIsExpanded(!isExpanded)} + isExpanded={isExpanded} + sectionId="section_short_form_content" + > + Short-Form Content + + + {isExpanded && ( +
+ + This section provides you with quick and catchy messages ideal for + platforms where you need to be brief, like Twitter. Short-form + content helps you share your main ideas in a few words, making it + easy to grab people's attention. It's perfect for sharing + updates, tips, or interesting thoughts that can engage your audience + without taking much of their time. + + + {data.map((content, idx) => ( +
+ onReport( + `social_media_campaigns.short_form_content.${content.platform}` + ) + } + > +
+

+ Content: +

+ + + {content.content.split('\n').map((line, index) => ( + + {line} +
+
+ ))} +
+ +

+ Adaptation Tips: +

+ + + +

+ Image Prompt for AI: +

+ + {content.imagePrompt} +
+
+ ))} +
+ )} +
+ ) +} + +export default SectionShortFormContent diff --git a/src/app/ideas/[id]/social_media_campaigns/components/SectionVideoContent.tsx b/src/app/ideas/[id]/social_media_campaigns/components/SectionVideoContent.tsx new file mode 100644 index 0000000..9775e01 --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/components/SectionVideoContent.tsx @@ -0,0 +1,91 @@ +'use client' + +import React, { useState } from 'react' +import Paragraph from '@/components/Paragraph' +import Section from '@/components/Section' +import SectionDescription from '@/components/SectionDescription' +import SectionHeader from '@/components/SectionHeader' +import SectionWrapper from '@/components/SectionWrapper' +import SimpleUnorderedList from '@/components/SimpleUnorderedList' + +interface SectionVideoContentProps { + onReport: (section: string) => void + data: Array<{ + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string + }> +} + +const SectionVideoContent: React.FC = ({ + onReport, + data, +}) => { + const [isExpanded, setIsExpanded] = useState(true) + + return ( + + setIsExpanded(!isExpanded)} + isExpanded={isExpanded} + sectionId="section_video_content" + > + Video Content + + + {isExpanded && ( +
+ + This section offers you more detailed content ideas like blog posts + or articles. Long-form content lets you explore topics in depth, + giving valuable information to your readers. It's a great way + to share your knowledge, tell stories, and connect with your + audience on a deeper level. + + + {data.map((content, idx) => ( +
+ onReport( + `social_media_campaigns.video_content.${content.platform}` + ) + } + > +
+

+ Title: +

+ {content.title} + +

+ Script Outline: +

+ + + +

+ Adaptation Tips: +

+ + + +

+ Image Prompt for AI: +

+ + {content.imagePrompt} +
+
+ ))} +
+ )} +
+ ) +} + +export default SectionVideoContent diff --git a/src/app/ideas/[id]/social_media_campaigns/page.tsx b/src/app/ideas/[id]/social_media_campaigns/page.tsx new file mode 100644 index 0000000..bd1ad74 --- /dev/null +++ b/src/app/ideas/[id]/social_media_campaigns/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from 'next/navigation' +import React from 'react' +import { App } from '@/idea/service/Service' +import { SocialMediaCampaigns } from './SocialMediaCampaigns' + +export const dynamic = 'force-dynamic' + +export default async function Page({ params }: { params: { id: string } }) { + try { + const dto = await App.Queries.GetSocialMediaCampaigns.handle({ + id: params.id, + }) + + return + } catch (e) { + if (e instanceof Error) { + if ('isNotFoundError' in e) { + notFound() + } + + return

{e.message}

+ } + + return

An unexpected error occurred

+ } +} diff --git a/src/idea/adapters/IdeaRepositorySQLite.ts b/src/idea/adapters/IdeaRepositorySQLite.ts index 717df9f..4392c34 100644 --- a/src/idea/adapters/IdeaRepositorySQLite.ts +++ b/src/idea/adapters/IdeaRepositorySQLite.ts @@ -8,6 +8,7 @@ import { MarketAnalysis } from '@/idea/domain/MarketAnalysis' import { ProductName } from '@/idea/domain/ProductName' import { Repository } from '@/idea/domain/Repository' import { SWOTAnalysis } from '@/idea/domain/SWOTAnalysis' +import { SocialMediaCampaigns } from '@/idea/domain/SocialMediaCampaigns' import { TargetAudience } from '@/idea/domain/TargetAudience' import { ValueProposition } from '@/idea/domain/ValueProposition' import { prisma } from '@/lib/prisma' @@ -233,6 +234,27 @@ export class IdeaRepositorySQLite implements Repository { }, }) } + + const socialMediaCampaigns = updatedIdea.getSocialMediaCampaigns() + if (socialMediaCampaigns) { + await prisma.ideaContent.upsert({ + where: { + ideaId_key: { + ideaId: id, + key: 'social_media_campaigns', + }, + }, + create: { + ideaId: id, + key: 'social_media_campaigns', + value: JSON.stringify(socialMediaCampaigns), + }, + update: { + value: JSON.stringify(socialMediaCampaigns), + updatedAt: new Date(), + }, + }) + } }) } @@ -587,4 +609,65 @@ export class IdeaRepositorySQLite implements Repository { return contentIdeasForMarketing } + + async getSocialMediaCampaignsByIdeaId( + ideaId: string + ): Promise { + const socialMediaCampaignsModel = await prisma.ideaContent.findUnique({ + where: { + ideaId_key: { + ideaId: ideaId, + key: 'social_media_campaigns', + }, + }, + }) + + if (!socialMediaCampaignsModel) { + return null + } + + interface campaigns { + shortFormContents: Array<{ + header: string + platform: string + content: string + tips: string[] + imagePrompt: string + }> + longFormContents: Array<{ + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string + }> + videoContents: Array<{ + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string + }> + } + + const data = JSON.parse(socialMediaCampaignsModel.value) as campaigns + + const contentIdeasForMarketing = SocialMediaCampaigns.New() + + data.shortFormContents.forEach((content) => { + contentIdeasForMarketing.addShortFormContent(content) + }) + + data.longFormContents.forEach((content) => { + contentIdeasForMarketing.addLongFormContent(content) + }) + + data.videoContents.forEach((content) => { + contentIdeasForMarketing.addVideoContent(content) + }) + + return contentIdeasForMarketing + } } diff --git a/src/idea/adapters/OpenAIService/SocialMediaCampaignsEvaluator.ts b/src/idea/adapters/OpenAIService/SocialMediaCampaignsEvaluator.ts new file mode 100644 index 0000000..ee048a2 --- /dev/null +++ b/src/idea/adapters/OpenAIService/SocialMediaCampaignsEvaluator.ts @@ -0,0 +1,233 @@ +import * as Sentry from '@sentry/nextjs' +import OpenAI from 'openai' +import { zodResponseFormat } from 'openai/helpers/zod' +import { z } from 'zod' +import { getPromptContent } from '@/lib/prompts' + +interface ShortFormContent { + header: string + platform: string + content: string + tips: string[] + imagePrompt: string +} + +interface LongFormContent { + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string +} + +interface VideoContent { + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string +} + +type Evaluation = { + shortFormContents: ShortFormContent[] + longFormContents: LongFormContent[] + videoContents: VideoContent[] +} + +interface TargetAudience { + segment: string + description: string + challenges: string[] +} + +interface ValueProposition { + mainBenefit: string + problemSolving: string +} + +const shortFormContentSchema = z.object({ + header: z.string(), + platform: z.string(), + content: z.string(), + tips: z.array(z.string()), + image_prompt: z.string(), +}) + +const longFormContentSchema = z.object({ + header: z.string(), + platform: z.string(), + title: z.string(), + content: z.string(), + tips: z.array(z.string()), + image_prompt: z.string(), +}) + +const videoContentSchema = z.object({ + header: z.string(), + platform: z.string(), + title: z.string(), + script: z.array(z.string()), + tips: z.array(z.string()), + image_prompt: z.string(), +}) + +const ResponseSchema = z.object({ + social_media_campaigns: z.object({ + short_form_content: z.array(shortFormContentSchema), + long_form_content: z.array(longFormContentSchema), + video_content: z.array(videoContentSchema), + }), +}) + +export class SocialMediaCampaignsEvaluator { + static className = 'SocialMediaCampaignsEvaluator' + static prompt = '00-social-media-campaigns' + static model = 'gpt-4o-mini' + static nucleusSampling = 0.9 + static maxCompletionTokens = 4000 + + private readonly openai: OpenAI + + constructor(apiKey: string) { + this.openai = new OpenAI({ + apiKey: apiKey, + }) + } + + async evaluateSocialMediaCampaigns( + ideaId: string, + problem: string, + targetAudiences: TargetAudience[], + valueProposition: ValueProposition + ): Promise { + Sentry.setTag('component', 'AIService') + Sentry.setTag('ai_service_type', SocialMediaCampaignsEvaluator.className) + Sentry.setTag('idea_id', ideaId) + + try { + const promptContent = getPromptContent( + SocialMediaCampaignsEvaluator.prompt + ) + + if (!promptContent) { + throw new Error( + `Prompt content ${SocialMediaCampaignsEvaluator.prompt} not found` + ) + } + + const response = await this.openai.beta.chat.completions.parse({ + model: SocialMediaCampaignsEvaluator.model, + messages: [ + { + role: 'system', + content: [ + { + type: 'text', + text: promptContent.trim(), + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'text', + text: `Here is the problem my product aims to solve: """ +${problem.trim()}""" + +Here are my segments: """ +${targetAudiences + .map((targetAudience, idx) => { + let content = '' + + content += `Segment ${idx + 1}: ${targetAudience.segment}\n` + content += `Description: ${targetAudience.description}\n` + content += `Challenges:\n${targetAudience.challenges.join('; ')}\n\n` + + return content + }) + .join('\n\n')} +""" + +And here is my value proposition: +- Main benefit: ${valueProposition.mainBenefit} +- Problem solving: ${valueProposition.problemSolving}`, + }, + ], + }, + ], + temperature: SocialMediaCampaignsEvaluator.nucleusSampling, + max_completion_tokens: + SocialMediaCampaignsEvaluator.maxCompletionTokens, + response_format: zodResponseFormat( + ResponseSchema, + 'social_media_campaigns' + ), + n: 1, + }) + + Sentry.addBreadcrumb({ + message: `OpenAI ${SocialMediaCampaignsEvaluator.className} called`, + data: { + model: SocialMediaCampaignsEvaluator.model, + top_p: SocialMediaCampaignsEvaluator.nucleusSampling, + max_completion_tokens: + SocialMediaCampaignsEvaluator.maxCompletionTokens, + usage: response.usage, + choices: response.choices.length, + }, + level: 'info', + }) + + const message = response.choices[0].message + + if (message.refusal) { + // TODO: Handle refusal + throw new Error('Message refusal: ' + message.refusal) + } + + if (!message.parsed) { + // TODO: Add Sentry message context + throw new Error('Message was not parsed') + } + + const socialMediaCampaigns = message.parsed.social_media_campaigns + + return { + shortFormContents: socialMediaCampaigns.short_form_content.map( + (content) => ({ + header: content.header, + platform: content.platform, + content: content.content, + tips: content.tips, + imagePrompt: content.image_prompt, + }) + ), + longFormContents: socialMediaCampaigns.long_form_content.map( + (content) => ({ + header: content.header, + platform: content.platform, + title: content.title, + content: content.content, + tips: content.tips, + imagePrompt: content.image_prompt, + }) + ), + videoContents: socialMediaCampaigns.video_content.map((content) => ({ + header: content.header, + platform: content.platform, + title: content.title, + script: content.script, + tips: content.tips, + imagePrompt: content.image_prompt, + })), + } + } catch (e) { + Sentry.captureException(e) + + throw e + } + } +} diff --git a/src/idea/app/App.ts b/src/idea/app/App.ts index b253276..f18df18 100644 --- a/src/idea/app/App.ts +++ b/src/idea/app/App.ts @@ -1,6 +1,8 @@ import { ArchivationHandler } from '@/idea/app/commands/Archive' import { MakeReservationHandler } from '@/idea/app/commands/MakeReservation' +import { RequestSocialMediaCampaignsHandler } from '@/idea/app/commands/RequestSocialMediaCampaigns' import { GetIdeaHandler } from '@/idea/app/queries/GetIdea' +import { GetSocialMediaCampaignsHandler } from '@/idea/app/queries/GetSocialMediaCampaigns' export type Application = { Commands: Commands @@ -10,8 +12,10 @@ export type Application = { type Commands = { MakeReservation: MakeReservationHandler Archive: ArchivationHandler + RequestSocialMediaCampaigns: RequestSocialMediaCampaignsHandler } type Queries = { GetIdea: GetIdeaHandler + GetSocialMediaCampaigns: GetSocialMediaCampaignsHandler } diff --git a/src/idea/app/commands/RequestSocialMediaCampaigns.ts b/src/idea/app/commands/RequestSocialMediaCampaigns.ts new file mode 100644 index 0000000..ab70155 --- /dev/null +++ b/src/idea/app/commands/RequestSocialMediaCampaigns.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/nextjs' +import { Repository } from '@/idea/domain/Repository' +import { SocialMediaCampaignsRequested } from '@/idea/domain/events/SocialMediaCampaignsRequested' +import { EventBus } from '@/idea/events/EventBus' + +type Command = { + ideaId: string +} + +export class RequestSocialMediaCampaignsHandler { + constructor( + private readonly repository: Repository, + private readonly eventBus: EventBus + ) {} + + async handle(command: Command): Promise { + Sentry.setTag('component', 'Command') + Sentry.setTag('command_type', 'RequestSocialMediaCampaigns') + Sentry.setTag('idea_id', command.ideaId) + + try { + const idea = await this.repository.getById(command.ideaId) + + if (!idea) { + throw new Error(`Idea ${command.ideaId} does not exist`) + } + + const socialMediaCampaigns = + await this.repository.getSocialMediaCampaignsByIdeaId(command.ideaId) + + if (socialMediaCampaigns) { + return + } + + this.eventBus.emit(new SocialMediaCampaignsRequested(command.ideaId)) + } catch (e) { + Sentry.captureException(e, { + contexts: { + idea: { + idea_id: command.ideaId, + status: 'social_media_campaigns_request_error', + }, + }, + }) + + throw e + } + } +} diff --git a/src/idea/app/queries/GetSocialMediaCampaigns.ts b/src/idea/app/queries/GetSocialMediaCampaigns.ts new file mode 100644 index 0000000..42f157d --- /dev/null +++ b/src/idea/app/queries/GetSocialMediaCampaigns.ts @@ -0,0 +1,86 @@ +import * as Sentry from '@sentry/nextjs' +import { NotFoundError } from '@/common/errors/NotFoundError' +import { Idea } from '@/idea/domain/Aggregate' +import { SocialMediaCampaigns } from '@/idea/domain/SocialMediaCampaigns' + +type Query = { + id: string +} + +interface ShortFormContentDTO { + header: string + platform: string + content: string + tips: string[] + imagePrompt: string +} + +interface LongFormContentDTO { + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string +} + +interface VideoContentDTO { + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string +} + +interface DTO { + id: string + contents: { + shortFormContent: Array + longFormContent: Array + videoContent: Array + } | null +} + +interface ReadModel { + getById(id: string): Promise + getSocialMediaCampaignsByIdeaId( + ideaId: string + ): Promise +} + +export class GetSocialMediaCampaignsHandler { + constructor(private readonly readModel: ReadModel) {} + + async handle(query: Query): Promise { + Sentry.setTag('component', 'Query') + Sentry.setTag('query_type', 'GetSocialMediaCampaigns') + Sentry.setTag('idea_id', query.id) + + try { + const idea = await this.readModel.getById(query.id) + + if (!idea) { + throw new NotFoundError(`Idea ${query.id} does not exist`) + } + + const socialMediaCampaigns = + await this.readModel.getSocialMediaCampaignsByIdeaId(query.id) + + return { + id: idea.getId().getValue(), + contents: socialMediaCampaigns + ? { + shortFormContent: socialMediaCampaigns.getShortFormContents(), + longFormContent: socialMediaCampaigns.getLongFormContents(), + videoContent: socialMediaCampaigns.getVideoContents(), + } + : null, + } + } catch (e) { + Sentry.captureException(e) + + throw e + } + } +} diff --git a/src/idea/domain/Aggregate.ts b/src/idea/domain/Aggregate.ts index d5fd100..24b604a 100644 --- a/src/idea/domain/Aggregate.ts +++ b/src/idea/domain/Aggregate.ts @@ -6,6 +6,7 @@ import { MarketAnalysis } from '@/idea/domain/MarketAnalysis' import { Problem } from '@/idea/domain/Problem' import { ProductName } from '@/idea/domain/ProductName' import { SWOTAnalysis } from '@/idea/domain/SWOTAnalysis' +import { SocialMediaCampaigns } from '@/idea/domain/SocialMediaCampaigns' import { TargetAudience } from '@/idea/domain/TargetAudience' import { ValueProposition } from '@/idea/domain/ValueProposition' import { Identity } from '@/shared/Identity' @@ -25,6 +26,7 @@ export class Idea { private elevatorPitches: ElevatorPitch[] | null = null private googleTrendsKeywords: GoogleTrendsKeyword[] | null = null private contentIdeasForMarketing: ContentIdeasForMarketing | null = null + private socialMediaCampaigns: SocialMediaCampaigns | null = null private migrated: boolean = false private archived: boolean = false @@ -105,6 +107,12 @@ export class Idea { this.contentIdeasForMarketing = contentIdeas } + public addSocialMediaCampaigns( + socialMediaCampaigns: SocialMediaCampaigns + ): void { + this.socialMediaCampaigns = socialMediaCampaigns + } + public finalizeMigration(): void { if (this.migrated) { throw new Error('Idea was migrated') @@ -173,6 +181,10 @@ export class Idea { return this.contentIdeasForMarketing } + public getSocialMediaCampaigns(): SocialMediaCampaigns | null { + return this.socialMediaCampaigns + } + public isMigrated(): boolean { return this.migrated } diff --git a/src/idea/domain/Repository.ts b/src/idea/domain/Repository.ts index 6cfce8f..e06af09 100644 --- a/src/idea/domain/Repository.ts +++ b/src/idea/domain/Repository.ts @@ -1,4 +1,5 @@ import { Idea } from '@/idea/domain/Aggregate' +import { SocialMediaCampaigns } from '@/idea/domain/SocialMediaCampaigns' import { TargetAudience } from '@/idea/domain/TargetAudience' import { ValueProposition } from '@/idea/domain/ValueProposition' @@ -8,4 +9,7 @@ export interface Repository { getById(id: string): Promise getTargetAudiencesByIdeaId(ideaId: string): Promise getValuePropositionByIdeaId(ideaId: string): Promise + getSocialMediaCampaignsByIdeaId( + ideaId: string + ): Promise } diff --git a/src/idea/domain/SocialMediaCampaigns.ts b/src/idea/domain/SocialMediaCampaigns.ts new file mode 100644 index 0000000..4f7c53a --- /dev/null +++ b/src/idea/domain/SocialMediaCampaigns.ts @@ -0,0 +1,61 @@ +interface ShortFormContent { + header: string + platform: string + content: string + tips: string[] + imagePrompt: string +} + +interface LongFormContent { + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string +} + +interface VideoContent { + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string +} + +export class SocialMediaCampaigns { + private readonly shortFormContents: ShortFormContent[] = [] + private readonly longFormContents: LongFormContent[] = [] + private readonly videoContents: VideoContent[] = [] + + private constructor() {} + + static New(): SocialMediaCampaigns { + return new SocialMediaCampaigns() + } + + public addShortFormContent(shortFormContent: ShortFormContent): void { + this.shortFormContents.push(shortFormContent) + } + + public addLongFormContent(longFormContent: LongFormContent): void { + this.longFormContents.push(longFormContent) + } + + public addVideoContent(videoContent: VideoContent): void { + this.videoContents.push(videoContent) + } + + public getShortFormContents(): ShortFormContent[] { + return this.shortFormContents + } + + public getLongFormContents(): LongFormContent[] { + return this.longFormContents + } + + public getVideoContents(): VideoContent[] { + return this.videoContents + } +} diff --git a/src/idea/domain/events/SocialMediaCampaignsRequested.ts b/src/idea/domain/events/SocialMediaCampaignsRequested.ts new file mode 100644 index 0000000..4916377 --- /dev/null +++ b/src/idea/domain/events/SocialMediaCampaignsRequested.ts @@ -0,0 +1,12 @@ +import { Event } from '@/idea/events/Event' + +export class SocialMediaCampaignsRequested implements Event { + public readonly type = 'SocialMediaCampaignsRequested' + public readonly payload: { + id: string + } + + constructor(id: string) { + this.payload = { id } + } +} diff --git a/src/idea/events/subscribers/SocialMediaCampaignsSubscriber.ts b/src/idea/events/subscribers/SocialMediaCampaignsSubscriber.ts new file mode 100644 index 0000000..b65daea --- /dev/null +++ b/src/idea/events/subscribers/SocialMediaCampaignsSubscriber.ts @@ -0,0 +1,178 @@ +import * as Sentry from '@sentry/nextjs' +import { Idea } from '@/idea/domain/Aggregate' +import { Repository } from '@/idea/domain/Repository' +import { SocialMediaCampaigns } from '@/idea/domain/SocialMediaCampaigns' +import { SocialMediaCampaignsRequested } from '@/idea/domain/events/SocialMediaCampaignsRequested' +import { EventHandler } from '@/idea/events/EventHandler' + +interface ShortFormContent { + header: string + platform: string + content: string + tips: string[] + imagePrompt: string +} + +interface LongFormContent { + header: string + platform: string + title: string + content: string + tips: string[] + imagePrompt: string +} + +interface VideoContent { + header: string + platform: string + title: string + script: string[] + tips: string[] + imagePrompt: string +} + +type Evaluation = { + shortFormContents: ShortFormContent[] + longFormContents: LongFormContent[] + videoContents: VideoContent[] +} + +interface TargetAudience { + segment: string + description: string + challenges: string[] +} + +interface ValueProposition { + mainBenefit: string + problemSolving: string +} + +interface AIService { + evaluateSocialMediaCampaigns( + ideaId: string, + problem: string, + targetAudiences: TargetAudience[], + valueProposition: ValueProposition + ): Promise +} + +export class SocialMediaCampaignsSubscriber implements EventHandler { + static className = 'SocialMediaCampaignsSubscriber' + + constructor( + private readonly repository: Repository, + private readonly aiService: AIService + ) {} + + getName(): string { + return SocialMediaCampaignsSubscriber.className + } + + async handle(event: SocialMediaCampaignsRequested): Promise { + Sentry.setTag('component', 'BackgroundJob') + Sentry.setTag('job_type', this.getName()) + Sentry.setTag('event_type', event.type) + Sentry.setTag('idea_id', event.payload.id) + + Sentry.addBreadcrumb({ message: `${this.getName()} started` }) + + try { + const idea = await this.repository.getById(event.payload.id) + + if (!idea) { + throw new Error(`Unable to get idea by ID: ${event.payload.id}`) + } + + const targetAudiences = await this.repository.getTargetAudiencesByIdeaId( + idea.getId().getValue() + ) + + if (targetAudiences.length === 0) { + throw new Error( + `Idea ${event.payload.id} does not have target audiences` + ) + } + + const audiences = targetAudiences.map((targetAudience) => ({ + segment: targetAudience.getSegment(), + description: targetAudience.getDescription(), + challenges: targetAudience.getChallenges(), + })) + + const valueProposition = + await this.repository.getValuePropositionByIdeaId( + idea.getId().getValue() + ) + + if (!valueProposition) { + throw new Error( + `Idea ${event.payload.id} does not have value proposition` + ) + } + + const evaluation = await this.aiService.evaluateSocialMediaCampaigns( + idea.getId().getValue(), + idea.getProblem().getValue(), + audiences, + { + mainBenefit: valueProposition.getMainBenefit(), + problemSolving: valueProposition.getProblemSolving(), + } + ) + + const socialMediaCampaigns = SocialMediaCampaigns.New() + + evaluation.shortFormContents.forEach((content) => { + socialMediaCampaigns.addShortFormContent({ + header: content.header, + platform: content.platform, + content: content.content, + tips: content.tips, + imagePrompt: content.imagePrompt, + }) + }) + + evaluation.longFormContents.forEach((content) => { + socialMediaCampaigns.addLongFormContent({ + header: content.header, + platform: content.platform, + title: content.title, + content: content.content, + tips: content.tips, + imagePrompt: content.imagePrompt, + }) + }) + + evaluation.videoContents.forEach((content) => { + socialMediaCampaigns.addVideoContent({ + header: content.header, + platform: content.platform, + title: content.title, + script: content.script, + tips: content.tips, + imagePrompt: content.imagePrompt, + }) + }) + + await this.repository.updateIdea(event.payload.id, (idea): Idea => { + idea.addSocialMediaCampaigns(socialMediaCampaigns) + + return idea + }) + + // TODO: Emit Event + } catch (e) { + Sentry.captureException(e, { + contexts: { + idea: { + idea_id: event.payload.id, + status: 'social_media_campaigns_evaluation_error', + }, + }, + }) + + throw e + } + } +} diff --git a/src/idea/service/Service.ts b/src/idea/service/Service.ts index ab4c227..d1f410b 100644 --- a/src/idea/service/Service.ts +++ b/src/idea/service/Service.ts @@ -8,12 +8,15 @@ import { GoogleTrendsKeywordsEvaluator } from '@/idea/adapters/OpenAIService/Goo import { MarketAnalysisEvaluator } from '@/idea/adapters/OpenAIService/MarketAnalysisEvaluator' import { PotentialNamesEvaluator } from '@/idea/adapters/OpenAIService/PotentialNamesEvaluator' import { SWOTAnalysisEvaluator } from '@/idea/adapters/OpenAIService/SWOTAnalysisEvaluator' +import { SocialMediaCampaignsEvaluator } from '@/idea/adapters/OpenAIService/SocialMediaCampaignsEvaluator' import { TargetAudienceEvaluator } from '@/idea/adapters/OpenAIService/TargetAudienceEvaluator' import { ValuePropositionEvaluator } from '@/idea/adapters/OpenAIService/ValuePropositionEvaluator' import { Application } from '@/idea/app/App' import { ArchivationHandler } from '@/idea/app/commands/Archive' import { MakeReservationHandler } from '@/idea/app/commands/MakeReservation' +import { RequestSocialMediaCampaignsHandler } from '@/idea/app/commands/RequestSocialMediaCampaigns' import { GetIdeaHandler } from '@/idea/app/queries/GetIdea' +import { GetSocialMediaCampaignsHandler } from '@/idea/app/queries/GetSocialMediaCampaigns' import { CompetitorAnalysisEvaluationSubscriber } from '@/idea/events/subscribers/CompetitorAnalysisEvaluationSubscriber' import { ContentIdeasEvaluationSubscriber } from '@/idea/events/subscribers/ContentIdeasEvaluationSubscriber' import { ElevatorPitchesEvaluationSubscriber } from '@/idea/events/subscribers/ElevatorPitchesEvaluationSubscriber' @@ -21,6 +24,7 @@ import { GoogleTrendsKeywordsEvaluationSubscriber } from '@/idea/events/subscrib import { MarketAnalysisEvaluationSubscriber } from '@/idea/events/subscribers/MarketAnalysisEvaluationSubscriber' import { PotentialNamesEvaluationSubscriber } from '@/idea/events/subscribers/PotentialNamesEvaluationSubscriber' import { SWOTAnalysisEvaluationSubscriber } from '@/idea/events/subscribers/SWOTAnalysisEvaluationSubscriber' +import { SocialMediaCampaignsSubscriber } from '@/idea/events/subscribers/SocialMediaCampaignsSubscriber' import { TargetAudienceEvaluationSubscriber } from '@/idea/events/subscribers/TargetAudienceEvaluationSubscriber' import { ValuePropositionEvaluationSubscriber } from '@/idea/events/subscribers/ValuePropositionEvaluationSubscriber' import { env } from '@/lib/env' @@ -83,6 +87,11 @@ const registerApp = (): Application => { new ContentIdeasEvaluator(env.OPENAI_API_KEY) ) + const socialMediaCampaignsSubscriber = new SocialMediaCampaignsSubscriber( + ideaRepository, + new SocialMediaCampaignsEvaluator(env.OPENAI_API_KEY) + ) + eventBus.subscribe('IdeaCreated', targetAudienceEvaluationSubscriber) eventBus.subscribe('IdeaCreated', valuePropositionEvaluationSubscriber) eventBus.subscribe('IdeaCreated', marketAnalysisEvaluationSubscriber) @@ -104,6 +113,10 @@ const registerApp = (): Application => { 'ValuePropositionEvaluated', contentIdeasEvaluationSubscriber ) + eventBus.subscribe( + 'SocialMediaCampaignsRequested', + socialMediaCampaignsSubscriber + ) return { Commands: { @@ -113,9 +126,16 @@ const registerApp = (): Application => { eventBus ), Archive: new ArchivationHandler(ideaRepository, eventBus), + RequestSocialMediaCampaigns: new RequestSocialMediaCampaignsHandler( + ideaRepository, + eventBus + ), }, Queries: { GetIdea: new GetIdeaHandler(ideaRepository), + GetSocialMediaCampaigns: new GetSocialMediaCampaignsHandler( + ideaRepository + ), }, } }