From a8b4717e82d42af4eef822509f74d0a34beba433 Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Thu, 5 Mar 2026 22:43:04 -0500 Subject: [PATCH 01/18] feat(marketing): add DNA extraction, competitor analysis, and strategic positioning (#232) - Company DNA extraction with dual RAG queries - Competitor analysis via web search + LLM synthesis - MessagingStrategy from positioning module - Shared model factory for all pipeline LLM calls - Parallel research, optional prompt, competitiveAngle in output --- .../api/agents/documentQ&A/services/models.ts | 121 +---------------- src/lib/models.ts | 124 ++++++++++++++++++ .../tools/marketing-pipeline/competitor.ts | 88 +++++++++++++ src/lib/tools/marketing-pipeline/context.ts | 87 ++++++++++++ src/lib/tools/marketing-pipeline/generator.ts | 121 ++++++++++++----- .../tools/marketing-pipeline/positioning.ts | 65 +++++++++ src/lib/tools/marketing-pipeline/run.ts | 89 +++++++++---- src/lib/tools/marketing-pipeline/types.ts | 63 ++++++++- 8 files changed, 584 insertions(+), 174 deletions(-) create mode 100644 src/lib/models.ts create mode 100644 src/lib/tools/marketing-pipeline/competitor.ts create mode 100644 src/lib/tools/marketing-pipeline/positioning.ts diff --git a/src/app/api/agents/documentQ&A/services/models.ts b/src/app/api/agents/documentQ&A/services/models.ts index dff21c19..3d853660 100644 --- a/src/app/api/agents/documentQ&A/services/models.ts +++ b/src/app/api/agents/documentQ&A/services/models.ts @@ -1,116 +1,9 @@ -import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; -import { ChatAnthropic } from "@langchain/anthropic"; -import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; -import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; -import type { AIModelType } from "./types"; - -// Re-export type for convenience -export type { AIModelType }; - /** - * Get a chat model instance based on the model type - * - * Supports all model types defined in types.ts: - * - OpenAI: gpt-4o, gpt-5.2, gpt-5.1, gpt-5-nano, gpt-5-mini - * - Anthropic: claude-sonnet-4, claude-opus-4.5 - * - Google: gemini-2.5-flash, gemini-3-flash, gemini-3-pro + * Document Q&A chat model factory. + * Re-exports from shared lib so one place controls model config. */ -export function getChatModel(modelType: AIModelType): BaseChatModel { - switch (modelType) { - // OpenAI Models - case "gpt-4o": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-4o", - temperature: 0.7, - timeout: 600000, - }); - - case "gpt-5.2": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5.2", - temperature: 0.7, - timeout: 600000, - }); - - case "gpt-5.1": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5.1", - temperature: 0.7, - timeout: 600000, - }); - - case "gpt-5-nano": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5-nano-2025-08-07", - timeout: 300000, - }); - - case "gpt-5-mini": - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-5-mini-2025-08-07", - timeout: 600000, - }); - - // Anthropic Models - case "claude-sonnet-4": - return new ChatAnthropic({ - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - modelName: "claude-sonnet-4-20250514", - temperature: 0.7, - }); - - case "claude-opus-4.5": - return new ChatAnthropic({ - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - modelName: "claude-opus-4.5", - temperature: 0.7, - }); - - // Google Gemini Models - case "gemini-2.5-flash": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-2.5-flash", - temperature: 0.7, - }); - - case "gemini-3-flash": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-3-flash-preview", - temperature: 0.7, - }); - - case "gemini-3-pro": - return new ChatGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_AI_API_KEY, - model: "gemini-3-pro-preview", - temperature: 0.7, - }); - - // Default fallback - default: - return new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-4o", - temperature: 0.7, - timeout: 600000, - }); - } -} - -/** - * Get embeddings instance - */ -export function getEmbeddings(): OpenAIEmbeddings { - return new OpenAIEmbeddings({ - model: "text-embedding-ada-002", - openAIApiKey: process.env.OPENAI_API_KEY, - }); -} - +export { + getChatModel, + getEmbeddings, + type AIModelType, +} from "~/lib/models"; diff --git a/src/lib/models.ts b/src/lib/models.ts new file mode 100644 index 00000000..95e631f4 --- /dev/null +++ b/src/lib/models.ts @@ -0,0 +1,124 @@ +/** + * Shared chat model factory for use across the app (document Q&A, marketing pipeline, etc.). + * Swap models in one place; all callers use LangChain BaseChatModel. + */ +import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; + +export type AIModelType = + | "gpt-4o" + | "gpt-5.2" + | "gpt-5.1" + | "gpt-5-nano" + | "gpt-5-mini" + | "claude-sonnet-4" + | "claude-opus-4.5" + | "gemini-2.5-flash" + | "gemini-3-flash" + | "gemini-3-pro"; + +/** + * Get a chat model instance based on the model type. + * Supports OpenAI, Anthropic, and Google Gemini. + */ +export function getChatModel(modelType: AIModelType): BaseChatModel { + switch (modelType) { + case "gpt-4o": + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-4o", + temperature: 0.7, + timeout: 600000, + }); + + case "gpt-5.2": + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-5.2", + temperature: 0.7, + timeout: 600000, + }); + + case "gpt-5.1": + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-5.1", + temperature: 0.7, + timeout: 600000, + }); + + case "gpt-5-nano": + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-5-nano-2025-08-07", + timeout: 300000, + }); + + case "gpt-5-mini": + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-5-mini-2025-08-07", + temperature: 0.3, + timeout: 600000, + }); + + case "claude-sonnet-4": + return new ChatAnthropic({ + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + modelName: "claude-sonnet-4-20250514", + temperature: 0.7, + }); + + case "claude-opus-4.5": + return new ChatAnthropic({ + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + modelName: "claude-opus-4.5", + temperature: 0.7, + }); + + case "gemini-2.5-flash": + return new ChatGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_AI_API_KEY, + model: "gemini-2.5-flash", + temperature: 0.7, + }); + + case "gemini-3-flash": + return new ChatGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_AI_API_KEY, + model: "gemini-3-flash-preview", + temperature: 0.7, + }); + + case "gemini-3-pro": + return new ChatGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_AI_API_KEY, + model: "gemini-3-pro-preview", + temperature: 0.7, + }); + + default: + return new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + modelName: "gpt-4o", + temperature: 0.7, + timeout: 600000, + }); + } +} + +export function getEmbeddings(): OpenAIEmbeddings { + return new OpenAIEmbeddings({ + model: "text-embedding-ada-002", + openAIApiKey: process.env.OPENAI_API_KEY, + }); +} + +/** Marketing pipeline model config: one place to swap models per stage. */ +export const MARKETING_MODELS = { + dnaExtraction: "gpt-4o" as AIModelType, + competitorAnalysis: "gpt-4o" as AIModelType, + contentGeneration: "gpt-4o" as AIModelType, +} as const; diff --git a/src/lib/tools/marketing-pipeline/competitor.ts b/src/lib/tools/marketing-pipeline/competitor.ts new file mode 100644 index 00000000..7a9d2571 --- /dev/null +++ b/src/lib/tools/marketing-pipeline/competitor.ts @@ -0,0 +1,88 @@ +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { executeSearch } from "~/lib/tools/trend-search/web-search"; +import type { PlannedQuery } from "~/lib/tools/trend-search/types"; +import { getChatModel, MARKETING_MODELS } from "~/lib/models"; +import type { CompetitorAnalysis } from "~/lib/tools/marketing-pipeline/types"; +import { CompetitorAnalysisSchema } from "~/lib/tools/marketing-pipeline/types"; + +/** + * Build search queries to find competitor messaging and positioning. + */ +function buildCompetitorQueries(companyName: string, categories: string[]): PlannedQuery[] { + const categoryStr = categories.length > 0 ? categories.join(" ") : "industry"; + return [ + { + searchQuery: `${companyName} competitors ${categoryStr} positioning`, + category: "business", + rationale: "Find direct competitors and their positioning", + }, + { + searchQuery: `${categoryStr} market leaders alternative solutions 2025`, + category: "business", + rationale: "Find alternatives and market leaders", + }, + { + searchQuery: `${companyName} vs competitors comparison`, + category: "business", + rationale: "Find comparison content and differentiators", + }, + ]; +} + +/** + * Use web search + LLM to synthesize a competitor landscape for the company. + */ +export async function analyzeCompetitors(args: { + companyName: string; + categories: string[]; + companyContext?: string; +}): Promise { + const { companyName, categories, companyContext = "" } = args; + + const plannedQueries = buildCompetitorQueries(companyName, categories); + + let rawContext = companyContext; + try { + const { results } = await executeSearch(plannedQueries); + if (results.length > 0) { + rawContext += + "\n\nWeb search results (competitors / market):\n" + + results + .slice(0, 12) + .map( + (r, i) => + `${i + 1}. [${r.title}] ${r.content.slice(0, 200)}... (${r.url})`, + ) + .join("\n\n"); + } + } catch (error) { + console.warn("[marketing-pipeline] competitor web search failed:", error); + } + + if (!rawContext.trim()) { + rawContext = `Company: ${companyName}. Categories: ${categories.join(", ") || "Unknown"}. No search results.`; + } + + const systemPrompt = `You are a competitive intelligence analyst. Given company name, categories, and optional web search results about competitors and the market, produce a structured CompetitorAnalysis. + +Rules: +- Use ONLY information from the provided context and search results. Do not invent competitor names or quotes. +- If few or no results: return empty or short placeholder arrays and "Not enough data" style strings where needed. +- competitors: array of { name, positioning (1 sentence), weaknesses (1-3 short items) } for up to 5 competitors. +- ourAdvantages: 2-5 short phrases where our company clearly wins (infer from context or leave minimal). +- marketGaps: 2-4 opportunities competitors miss. +- messagingAntiPatterns: 2-4 clichés or messages competitors use that we should avoid. + +Return valid JSON matching the schema.`; + + const chat = getChatModel(MARKETING_MODELS.competitorAnalysis); + const model = chat.withStructuredOutput(CompetitorAnalysisSchema, { + name: "competitor_analysis", + }); + const response = await model.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(rawContext), + ]); + + return CompetitorAnalysisSchema.parse(response); +} diff --git a/src/lib/tools/marketing-pipeline/context.ts b/src/lib/tools/marketing-pipeline/context.ts index 84769e87..3e181b95 100644 --- a/src/lib/tools/marketing-pipeline/context.ts +++ b/src/lib/tools/marketing-pipeline/context.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; import { db } from "~/server/db"; import { category, company } from "~/server/db/schema"; import { @@ -7,6 +8,19 @@ import { type CompanySearchOptions, type SearchResult, } from "~/lib/tools/rag"; +import { getChatModel, MARKETING_MODELS } from "~/lib/models"; +import type { CompanyDNA } from "~/lib/tools/marketing-pipeline/types"; +import { CompanyDNASchema } from "~/lib/tools/marketing-pipeline/types"; + +const DIFFERENTIATOR_QUERY_PARTS = [ + "unique strengths", + "competitive advantages", + "awards", + "metrics", + "customer outcomes", + "open source", + "differentiator", +]; export async function buildCompanyKnowledgeContext(args: { companyId: number; @@ -52,3 +66,76 @@ export async function buildCompanyKnowledgeContext(args: { return contextParts.join("\n"); } +/** + * Run RAG for general context and for differentiators, then use LLM to distill into CompanyDNA. + */ +export async function extractCompanyDNA(args: { + companyId: number; + prompt: string; +}): Promise { + const { companyId, prompt } = args; + + const [companyRow, categoryRows] = await Promise.all([ + db.select().from(company).where(eq(company.id, companyId)).limit(1), + db.select().from(category).where(eq(category.companyId, BigInt(companyId))).limit(8), + ]); + + const companyInfo = companyRow[0]; + const categoryNames = categoryRows.map((r) => r.name).filter(Boolean); + const baseMeta = `Company: ${companyInfo?.name ?? "Unknown"}. Categories: ${categoryNames.join(", ") || "None"}.`; + + const embeddings = createOpenAIEmbeddings(); + const options: CompanySearchOptions = { companyId, topK: 4, weights: [0.4, 0.6] }; + + let generalSnippets: string[] = []; + let differentiatorSnippets: string[] = []; + + try { + const [generalResults, diffResults] = await Promise.all([ + companyEnsembleSearch(prompt, options, embeddings), + companyEnsembleSearch( + `${baseMeta} ${DIFFERENTIATOR_QUERY_PARTS.join(" ")}`, + { ...options, topK: 4 }, + embeddings, + ), + ]); + + generalSnippets = generalResults + .slice(0, 4) + .map((r) => r.pageContent.trim().replace(/\s+/g, " ").slice(0, 320)) + .filter(Boolean); + differentiatorSnippets = diffResults + .slice(0, 4) + .map((r) => r.pageContent.trim().replace(/\s+/g, " ").slice(0, 320)) + .filter(Boolean); + } catch (error) { + console.warn("[marketing-pipeline] extractCompanyDNA RAG failed:", error); + } + + const combinedSnippets = [...new Set([...generalSnippets, ...differentiatorSnippets])]; + const rawContext = + combinedSnippets.length > 0 + ? combinedSnippets.map((s, i) => `${i + 1}. ${s}`).join("\n\n") + : `Company Name: ${companyInfo?.name ?? "Unknown Company"}. No KB snippets available.`; + + const systemPrompt = `You are a strategist. Given raw company knowledge-base snippets, distill them into a structured CompanyDNA. +Rules: +- Use ONLY information present in the snippets. Do not invent. +- If something is missing, use a short placeholder like "Not specified" or an empty array. +- coreMission: one sentence on what the company does and for whom. +- keyDifferentiators: 2-5 short phrases (e.g. "open source", "no vendor lock-in"). +- provenResults: metrics, outcomes, awards, case results mentioned. +- humanStory: founding story, team ethos, or values if present; otherwise "Not specified". +- technicalEdge: one simple sentence on how it works or why it's better; keep it non-technical. +Return valid JSON matching the schema.`; + + const chat = getChatModel(MARKETING_MODELS.dnaExtraction); + const model = chat.withStructuredOutput(CompanyDNASchema, { name: "company_dna" }); + const response = await model.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(`Raw KB snippets:\n\n${rawContext}\n\nUser focus: ${prompt}`), + ]); + + return CompanyDNASchema.parse(response); +} + diff --git a/src/lib/tools/marketing-pipeline/generator.ts b/src/lib/tools/marketing-pipeline/generator.ts index 12858447..e4811a13 100644 --- a/src/lib/tools/marketing-pipeline/generator.ts +++ b/src/lib/tools/marketing-pipeline/generator.ts @@ -1,9 +1,13 @@ -import { ChatOpenAI } from "@langchain/openai"; import { HumanMessage, SystemMessage } from "@langchain/core/messages"; -import type { MarketingPlatform, MarketingResearchResult } from "~/lib/tools/marketing-pipeline/types"; +import { getChatModel, MARKETING_MODELS } from "~/lib/models"; +import type { + MarketingPlatform, + MarketingResearchResult, + MessagingStrategy, +} from "~/lib/tools/marketing-pipeline/types"; import { MarketingPipelineOutputSchema } from "~/lib/tools/marketing-pipeline/types"; -const SYSTEM_PROMPT = `You are a marketing campaign copywriter for B2B products. +const SYSTEM_PROMPT_BASE = `You are a marketing campaign copywriter for B2B products. You create a platform-ready campaign message using: - User prompt @@ -22,6 +26,13 @@ Rules: 6) Pick "image" when a static visual would help (diagram, workflow, checklist). Pick "video" when a demo/explainer makes more sense.`; +const STRATEGY_RULES = ` +When a Messaging Strategy is provided: +- Lead with the recommended angle and human hook when it fits the platform; balance human story with technical depth. +- Back claims with the key proof points given; do not add proof not in company context. +- Do NOT use any phrase or theme in the strategy's avoid list. +- Keep the post aligned with the positioning angle while staying platform-native.`; + function platformTemplate(platform: MarketingPlatform): string { switch (platform) { case "x": @@ -75,28 +86,48 @@ return research } +function formatStrategyBlock(strategy: MessagingStrategy): string { + return [ + `Positioning angle: ${strategy.angle}`, + `Key proof: ${strategy.keyProof.join("; ")}`, + `Human hook: ${strategy.humanHook}`, + `Avoid: ${strategy.avoidList.join("; ")}`, + ].join("\n"); +} + function buildPrompt(args: { platform: MarketingPlatform; prompt: string; companyContext: string; research: MarketingResearchResult[]; + strategy?: MessagingStrategy; }): string { - return `Selected platform: ${args.platform} -User prompt: ${args.prompt} - -Company context (source of truth): -${args.companyContext} - -Trend references (optional angles, do not quote, do not claim facts from them): -${formatTrendReferences(args.research)} - -${platformTemplate(args.platform)} - -Task: -- Pick ONE angle (either from trend references or company context). -- Write ONE post that fits the platform format above. -- Do NOT add facts not supported by company context. -- Return JSON only matching the schema.`; + const parts = [ + `Selected platform: ${args.platform}`, + `User prompt: ${args.prompt}`, + "", + "Company context (source of truth):", + args.companyContext, + "", + "Trend references (optional angles, do not quote, do not claim facts from them):", + formatTrendReferences(args.research), + ]; + if (args.strategy) { + parts.push("", "Messaging strategy (use this angle and proof; respect avoid list):", formatStrategyBlock(args.strategy)); + } + parts.push( + "", + platformTemplate(args.platform), + "", + "Task:", + args.strategy + ? "- Write ONE post using the messaging strategy angle and proof; respect the avoid list." + : "- Pick ONE angle (either from trend references or company context).", + "- Write ONE post that fits the platform format above.", + "- Do NOT add facts not supported by company context.", + "- Return JSON only matching the schema.", + ); + return parts.join("\n"); } export async function generateCampaignOutput(args: { @@ -104,22 +135,40 @@ export async function generateCampaignOutput(args: { prompt: string; companyContext: string; research: MarketingResearchResult[]; -}) { - const chat = new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: "gpt-4o-mini", - temperature: 0.3, - }); - - const model = chat.withStructuredOutput(MarketingPipelineOutputSchema, { - name: "marketing_pipeline_output", - }); - - const response = await model.invoke([ - new SystemMessage(SYSTEM_PROMPT), - new HumanMessage(buildPrompt(args)), - ]); - - return MarketingPipelineOutputSchema.parse(response); + strategy?: MessagingStrategy; +}): Promise<{ + platform: MarketingPlatform; + message: string; + "image/video": "image" | "video"; + competitiveAngle?: string; + strategyUsed?: MessagingStrategy; +}> { + const systemPrompt = args.strategy + ? SYSTEM_PROMPT_BASE + STRATEGY_RULES + : SYSTEM_PROMPT_BASE; + + const chat = getChatModel(MARKETING_MODELS.contentGeneration); + const model = chat.withStructuredOutput(MarketingPipelineOutputSchema, { + name: "marketing_pipeline_output", + }); + + const response = await model.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(buildPrompt(args)), + ]); + + const parsed = MarketingPipelineOutputSchema.parse(response); + const out: { + platform: MarketingPlatform; + message: string; + "image/video": "image" | "video"; + competitiveAngle?: string; + strategyUsed?: MessagingStrategy; + } = { ...parsed }; + if (args.strategy) { + out.competitiveAngle = args.strategy.angle; + out.strategyUsed = args.strategy; + } + return out; } diff --git a/src/lib/tools/marketing-pipeline/positioning.ts b/src/lib/tools/marketing-pipeline/positioning.ts new file mode 100644 index 00000000..77b5774d --- /dev/null +++ b/src/lib/tools/marketing-pipeline/positioning.ts @@ -0,0 +1,65 @@ +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { getChatModel, MARKETING_MODELS } from "~/lib/models"; +import type { + CompanyDNA, + CompetitorAnalysis, + MessagingStrategy, +} from "~/lib/tools/marketing-pipeline/types"; +import { MessagingStrategySchema } from "~/lib/tools/marketing-pipeline/types"; + +/** + * Build a single MessagingStrategy from company DNA, competitor analysis, and optional trend summary. + */ +export async function buildMessagingStrategy(args: { + dna: CompanyDNA; + competitors: CompetitorAnalysis; + trendsSummary?: string; + userPrompt?: string; +}): Promise { + const { dna, competitors, trendsSummary = "", userPrompt = "" } = args; + + const contextParts: string[] = [ + "## Company DNA", + `Mission: ${dna.coreMission}`, + `Differentiators: ${dna.keyDifferentiators.join("; ")}`, + `Proven results: ${dna.provenResults.join("; ")}`, + `Human story: ${dna.humanStory}`, + `Technical edge: ${dna.technicalEdge}`, + "", + "## Competitor landscape", + ...competitors.competitors.map( + (c) => + `- ${c.name}: ${c.positioning}. Weaknesses: ${c.weaknesses.join(", ")}`, + ), + `Our advantages: ${competitors.ourAdvantages.join("; ")}`, + `Market gaps: ${competitors.marketGaps.join("; ")}`, + `Messaging to avoid: ${competitors.messagingAntiPatterns.join("; ")}`, + ]; + if (trendsSummary.trim()) { + contextParts.push("", "## Platform / trend context", trendsSummary.trim()); + } + if (userPrompt.trim()) { + contextParts.push("", "## User request", userPrompt.trim()); + } + + const systemPrompt = `You are a messaging strategist. Given company DNA, competitor analysis, and optional trend context, produce a single MessagingStrategy. + +Rules: +- angle: one clear positioning angle (1–2 sentences) that differentiates us from competitors and fits our DNA. +- keyProof: 2–4 concrete proof points (metrics, outcomes, differentiators) to support the angle. +- humanHook: one human story or emotional hook to open or close the message. +- avoidList: 3–5 phrases or themes we must avoid (clichés, competitor overlap, weak claims). + +Use only information from the provided context. Return valid JSON matching the schema.`; + + const chat = getChatModel(MARKETING_MODELS.contentGeneration); + const model = chat.withStructuredOutput(MessagingStrategySchema, { + name: "messaging_strategy", + }); + const response = await model.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(contextParts.join("\n")), + ]); + + return MessagingStrategySchema.parse(response); +} diff --git a/src/lib/tools/marketing-pipeline/run.ts b/src/lib/tools/marketing-pipeline/run.ts index 903c92e5..0a62fb86 100644 --- a/src/lib/tools/marketing-pipeline/run.ts +++ b/src/lib/tools/marketing-pipeline/run.ts @@ -1,5 +1,7 @@ -import { buildCompanyKnowledgeContext } from "~/lib/tools/marketing-pipeline/context"; +import { buildCompanyKnowledgeContext, extractCompanyDNA } from "~/lib/tools/marketing-pipeline/context"; import { generateCampaignOutput } from "~/lib/tools/marketing-pipeline/generator"; +import { analyzeCompetitors } from "~/lib/tools/marketing-pipeline/competitor"; +import { buildMessagingStrategy } from "~/lib/tools/marketing-pipeline/positioning"; import type { MarketingPipelineInput, MarketingPipelineResult, @@ -9,13 +11,16 @@ import type { // additional imports for query building and for fetching trend information import { eq } from "drizzle-orm"; import { db } from "~/server/db"; -import { company } from "~/server/db/schema"; +import { company, category } from "~/server/db/schema"; import { researchPlatformTrends } from "~/lib/tools/marketing-pipeline/research"; +const DEFAULT_PROMPT = "Generate a compelling campaign post for this platform."; + function normalizeInput(input: MarketingPipelineInput): MarketingPipelineInput { + const prompt = input.prompt?.trim().replace(/\s+/g, " ") ?? DEFAULT_PROMPT; return { platform: input.platform, - prompt: input.prompt.trim().replace(/\s+/g, " "), + prompt: prompt || DEFAULT_PROMPT, maxResearchResults: input.maxResearchResults ?? 6, }; } @@ -32,59 +37,95 @@ function normalizeResearch(research: MarketingResearchResult[]): MarketingResear })); } +function formatTrendsSummary(research: MarketingResearchResult[]): string { + if (!research.length) return ""; + return research + .slice(0, 6) + .map((r) => `${r.title}: ${r.snippet.slice(0, 180)}`) + .join("\n"); +} + export async function runMarketingPipeline(args: { companyId: number; input: MarketingPipelineInput; }): Promise { const normalizedInput = normalizeInput(args.input); - // 1) Fetch company name (used for logs / future features) + // 1) Fetch company name and categories const [companyRow] = await db .select({ name: company.name }) .from(company) .where(eq(company.id, args.companyId)) .limit(1); - const companyName = companyRow?.name ?? "Unknown Company"; - // 2) Build KB context from all company documents + const categoryRows = await db + .select({ name: category.name }) + .from(category) + .where(eq(category.companyId, BigInt(args.companyId))) + .limit(8); + const categories = categoryRows.map((r) => r.name).filter(Boolean); + + // 2) Build KB context (needed for research and generator) const companyContextBase = await buildCompanyKnowledgeContext({ companyId: args.companyId, prompt: normalizedInput.prompt, }); - // 3) Add platform best practices const platformGuidelines = buildPlatformGuidelines(normalizedInput.platform); const companyContext = `${companyContextBase} Platform best practices: ${platformGuidelines}`; - // 4) Fetch trend references (non-fatal) + // 3) Run DNA extraction, competitor analysis, and trend research in parallel let research: MarketingResearchResult[] = []; - try { - research = await researchPlatformTrends({ - platform: normalizedInput.platform, - prompt: normalizedInput.prompt, + const [dna, competitors] = await Promise.all([ + extractCompanyDNA({ companyId: args.companyId, prompt: normalizedInput.prompt }), + analyzeCompetitors({ companyName, - companyContext, - maxResults: normalizedInput.maxResearchResults ?? 6, - }); - research = normalizeResearch(research); - } catch (error) { - console.warn("[marketing-pipeline] trend research failed:", error); - research = []; - } - - // 5) Generate campaign output using KB + trends + categories, + companyContext: companyContextBase, + }), + (async (): Promise => { + try { + const raw = await researchPlatformTrends({ + platform: normalizedInput.platform, + prompt: normalizedInput.prompt, + companyName, + companyContext, + maxResults: normalizedInput.maxResearchResults ?? 6, + }); + return normalizeResearch(raw); + } catch (error) { + console.warn("[marketing-pipeline] trend research failed:", error); + return []; + } + })(), + ]).then(([d, c, r]) => { + research = r; + return [d, c] as const; + }); + + // 4) Build messaging strategy from DNA + competitors + trends + const trendsSummary = formatTrendsSummary(research); + const strategy = await buildMessagingStrategy({ + dna, + competitors, + trendsSummary, + userPrompt: normalizedInput.prompt, + }); + + // 5) Generate campaign output with strategy const generated = await generateCampaignOutput({ platform: normalizedInput.platform, prompt: normalizedInput.prompt, companyContext, research, + strategy, }); - // 6) Return final result + // 6) Return result with competitiveAngle and strategyUsed return { ...generated, research, @@ -92,6 +133,8 @@ ${platformGuidelines}`; platform: normalizedInput.platform, prompt: normalizedInput.prompt, }, + competitiveAngle: generated.competitiveAngle, + strategyUsed: generated.strategyUsed, }; } diff --git a/src/lib/tools/marketing-pipeline/types.ts b/src/lib/tools/marketing-pipeline/types.ts index 90cca09a..97c0b89d 100644 --- a/src/lib/tools/marketing-pipeline/types.ts +++ b/src/lib/tools/marketing-pipeline/types.ts @@ -1,11 +1,68 @@ import { z } from "zod"; +/** Structured company profile distilled from KB for marketing (issue #232). */ +export interface CompanyDNA { + coreMission: string; + keyDifferentiators: string[]; + provenResults: string[]; + humanStory: string; + technicalEdge: string; +} + +export const CompanyDNASchema = z.object({ + coreMission: z.string(), + keyDifferentiators: z.array(z.string()), + provenResults: z.array(z.string()), + humanStory: z.string(), + technicalEdge: z.string(), +}); + +/** Competitor landscape for marketing (issue #232). */ +export interface CompetitorAnalysis { + competitors: Array<{ + name: string; + positioning: string; + weaknesses: string[]; + }>; + ourAdvantages: string[]; + marketGaps: string[]; + messagingAntiPatterns: string[]; +} + +export const CompetitorAnalysisSchema = z.object({ + competitors: z.array( + z.object({ + name: z.string(), + positioning: z.string(), + weaknesses: z.array(z.string()), + }), + ), + ourAdvantages: z.array(z.string()), + marketGaps: z.array(z.string()), + messagingAntiPatterns: z.array(z.string()), +}); + +/** Messaging strategy derived from DNA + competitors + trends (issue #232). */ +export interface MessagingStrategy { + angle: string; + keyProof: string[]; + humanHook: string; + avoidList: string[]; +} + +export const MessagingStrategySchema = z.object({ + angle: z.string(), + keyProof: z.array(z.string()), + humanHook: z.string(), + avoidList: z.array(z.string()), +}); + export const MarketingPlatformEnum = z.enum(["x", "linkedin", "reddit", "bluesky"]); export type MarketingPlatform = z.infer; export const MarketingPipelineInputSchema = z.object({ platform: MarketingPlatformEnum, - prompt: z.string().min(1).max(2000), + prompt: z.string().min(1).max(2000).optional(), maxResearchResults: z.number().int().min(1).max(12).optional(), }); export type MarketingPipelineInput = z.infer; @@ -30,5 +87,9 @@ export interface MarketingPipelineResult extends MarketingPipelineOutput { platform: MarketingPlatform; prompt: string; }; + /** Positioning angle used for this campaign (issue #232). */ + competitiveAngle?: string; + /** Optional summary of strategy (angle + proof + hook) for transparency. */ + strategyUsed?: MessagingStrategy; } From 8254aad7685012055aca0d4df521bf8da5f9bcf7 Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Thu, 5 Mar 2026 23:47:10 -0500 Subject: [PATCH 02/18] fix: marketing pipeline build and type errors --- src/lib/tools/marketing-pipeline/run.ts | 48 ++++++++++++++++++++----- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/lib/tools/marketing-pipeline/run.ts b/src/lib/tools/marketing-pipeline/run.ts index 3959e94c..41418dd6 100644 --- a/src/lib/tools/marketing-pipeline/run.ts +++ b/src/lib/tools/marketing-pipeline/run.ts @@ -69,7 +69,7 @@ export async function runMarketingPipeline(args: { // 2) Build KB context (needed for research and generator) const companyContextBase = await buildCompanyKnowledgeContext({ companyId: args.companyId, - prompt: normalizedInput.prompt, + prompt: normalizedInput.prompt ?? DEFAULT_PROMPT, }); const platformGuidelines = buildPlatformGuidelines(normalizedInput.platform); @@ -81,7 +81,7 @@ ${platformGuidelines}`; // 3) Run DNA extraction, competitor analysis, and trend research in parallel let research: MarketingResearchResult[] = []; const [dna, competitors] = await Promise.all([ - extractCompanyDNA({ companyId: args.companyId, prompt: normalizedInput.prompt }), + extractCompanyDNA({ companyId: args.companyId, prompt: normalizedInput.prompt ?? DEFAULT_PROMPT }), analyzeCompetitors({ companyName, categories, @@ -91,14 +91,15 @@ ${platformGuidelines}`; try { const raw = await researchPlatformTrends({ platform: normalizedInput.platform, - prompt: normalizedInput.prompt, + prompt: normalizedInput.prompt ?? DEFAULT_PROMPT, companyName, companyContext, maxResults: normalizedInput.maxResearchResults ?? 6, }); return normalizeResearch(raw); - } catch (error) { - console.warn("[marketing-pipeline] trend research failed:", error); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[marketing-pipeline] trend research failed:", message); return []; } })(), @@ -113,13 +114,13 @@ ${platformGuidelines}`; dna, competitors, trendsSummary, - userPrompt: normalizedInput.prompt, + userPrompt: normalizedInput.prompt ?? DEFAULT_PROMPT, }); // 5) Generate campaign output with strategy const generated = await generateCampaignOutput({ platform: normalizedInput.platform, - prompt: normalizedInput.prompt, + prompt: normalizedInput.prompt ?? DEFAULT_PROMPT, companyContext, research, strategy, @@ -131,11 +132,40 @@ ${platformGuidelines}`; research, normalizedInput: { platform: normalizedInput.platform, - prompt: normalizedInput.prompt, + prompt: normalizedInput.prompt ?? DEFAULT_PROMPT, }, competitiveAngle: generated.competitiveAngle, strategyUsed: generated.strategyUsed, }; } - +function buildPlatformGuidelines(platform: MarketingPipelineInput["platform"]): string { + switch (platform) { + case "reddit": + return [ + "- Speak like a real community member, not a brand account.", + "- Lead with a specific pain point or story that matches the subreddit.", + "- Avoid pure self-promotion: focus on value, insight, or behind-the-scenes context.", + "- Use clear, descriptive titles; body can be longer and conversational.", + "- Invite discussion with an authentic question at the end.", + ].join("\n"); + case "x": + return [ + "- Keep posts tight and high-signal; front-load the hook in the first line.", + "- Use 1–2 sharp talking points instead of long paragraphs.", + "- Sprinkle in 1–2 relevant hashtags, but avoid hashtag spam.", + "- When appropriate, reference current trends or conversations in the space.", + "- Make the call-to-action explicit and easy to understand.", + ].join("\n"); + case "linkedin": + return [ + "- Use a strong first line that clearly states the outcome or insight.", + "- Write in short paragraphs or bullet points for easy scanning.", + "- Frame the message around business impact, transformation, or a concrete case.", + "- Keep the tone professional but human—less hype, more signal.", + "- Close with a takeaway or a soft call-to-action tailored to professionals.", + ].join("\n"); + default: + return "- Write a clear, concise, value-focused message tailored to this platform."; + } +} From 0235d9db3a0755fce7275ef4ef568a820b362ad3 Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Sat, 7 Mar 2026 16:58:16 -0500 Subject: [PATCH 03/18] UI improvement in the campaign draft to display the output in a preview --- .../tools/marketing-pipeline/page.tsx | 24 +++- src/env.ts | 1 + .../Employer/MarketingPipeline.module.css | 128 ++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/app/employer/tools/marketing-pipeline/page.tsx b/src/app/employer/tools/marketing-pipeline/page.tsx index 8e0d88fc..398d4ac8 100644 --- a/src/app/employer/tools/marketing-pipeline/page.tsx +++ b/src/app/employer/tools/marketing-pipeline/page.tsx @@ -3,6 +3,8 @@ import { useMemo, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { ArrowLeft, Brain, Loader2, MessageSquareText, Megaphone, Sparkles } from "lucide-react"; import ProfileDropdown from "~/app/employer/_components/ProfileDropdown"; import { ThemeToggle } from "~/app/_components/ThemeToggle"; @@ -270,10 +272,24 @@ export default function MarketingPipelinePage() { {result && !loading && (
Campaign draft
-
- {`platform: ${result.platform} -message: ${result.message} -image/video: ${result["image/video"]}`} +
+
+ + {result.platform === "reddit" ? ( + + ) : ( + result.platform === "x" ? "𝕏" : result.platform === "linkedin" ? "in" : "🦋" + )} + + + {PLATFORM_OPTIONS.find((p) => p.id === result.platform)?.label ?? result.platform} preview + +
+
+ + {result.message} + +
{result.research.length > 0 && ( diff --git a/src/env.ts b/src/env.ts index 45643b64..d252fb22 100644 --- a/src/env.ts +++ b/src/env.ts @@ -111,6 +111,7 @@ function parseServerEnv() { SIDECAR_URL: process.env.SIDECAR_URL, }); if ( + !skipValidation && (server.INNGEST_EVENT_KEY == null || server.INNGEST_EVENT_KEY.length === 0) ) { throw new Error("INNGEST_EVENT_KEY is required in production"); diff --git a/src/styles/Employer/MarketingPipeline.module.css b/src/styles/Employer/MarketingPipeline.module.css index 2cf42350..6103c0ac 100644 --- a/src/styles/Employer/MarketingPipeline.module.css +++ b/src/styles/Employer/MarketingPipeline.module.css @@ -512,6 +512,120 @@ box-shadow: 0 4px 16px rgba(124, 58, 237, 0.05); } +/* Platform preview card — post-style display */ +.platformPreviewCard { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 0.875rem; + overflow: hidden; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); +} + +.platformPreviewHeader { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.02); +} + +.platformPreviewBadge { + width: 1.75rem; + height: 1.75rem; + border-radius: 0.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.platformPreviewBadge img { + object-fit: contain; +} + +.platformPreviewBadgeReddit { + background: transparent; +} + +.platformPreviewBadgeImg { + width: 1.1rem; + height: 1.1rem; +} + +.platformPreviewBadgeX { + background: #000; +} + +.platformPreviewBadgeLinkedin { + background: #0a66c2; +} + +.platformPreviewBadgeBluesky { + background: #0085ff; +} + +.platformPreviewLabel { + flex: 1; + font-size: 0.8125rem; + font-weight: 500; + color: var(--muted-foreground); +} + +.mediaBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + color: var(--muted-foreground); + padding: 0.2rem 0.5rem; + border-radius: 0.375rem; + background: rgba(124, 58, 237, 0.08); + color: var(--foreground); +} + +.platformPreviewBody { + padding: 1rem 1.25rem; + font-size: 0.9rem; + line-height: 1.65; + color: var(--foreground); +} + +.platformPreviewBody p { + margin: 0 0 0.75em 0; +} + +.platformPreviewBody p:last-child { + margin-bottom: 0; +} + +.platformPreviewBody ul, +.platformPreviewBody ol { + margin: 0.5em 0 0.75em; + padding-left: 1.25em; +} + +.platformPreviewBody li { + margin-bottom: 0.25em; +} + +.platformPreviewBody strong { + font-weight: 600; +} + +.platformPreviewBody a { + color: #0a66c2; + text-decoration: none; +} + +.platformPreviewBody a:hover { + text-decoration: underline; +} + .assistantSectionHeader { font-weight: 700; font-size: 0.95rem; @@ -1026,3 +1140,17 @@ [data-theme="dark"] .backNavButton:hover { background: rgba(139, 92, 246, 0.15); } + +[data-theme="dark"] .platformPreviewCard { + background: rgba(15, 15, 30, 0.7); + border-color: rgba(139, 92, 246, 0.2); +} + +[data-theme="dark"] .platformPreviewHeader { + background: rgba(0, 0, 0, 0.2); + border-bottom-color: rgba(139, 92, 246, 0.15); +} + +[data-theme="dark"] .mediaBadge { + background: rgba(139, 92, 246, 0.15); +} From 8ebbb1d0d41751897b3bc807d3a4f6d28324b4b2 Mon Sep 17 00:00:00 2001 From: Rafael Chen Date: Sat, 7 Mar 2026 17:13:34 -0500 Subject: [PATCH 04/18] pipeline to rewrite feature w/o having to reroute the user --- .../components/generator/RewriteWorkflow.tsx | 32 +-- .../tools/marketing-pipeline/page.tsx | 96 ++++++- .../Employer/MarketingPipeline.module.css | 242 ++++++++++++++++++ 3 files changed, 346 insertions(+), 24 deletions(-) diff --git a/src/app/employer/documents/components/generator/RewriteWorkflow.tsx b/src/app/employer/documents/components/generator/RewriteWorkflow.tsx index 2088ebbe..9ed23e27 100644 --- a/src/app/employer/documents/components/generator/RewriteWorkflow.tsx +++ b/src/app/employer/documents/components/generator/RewriteWorkflow.tsx @@ -237,8 +237,8 @@ export function RewriteWorkflow({ initialText = "", onComplete, onCancel }: Rewr
)} -
-
+
+
-
+
-
+