- Add 'brief' field to GenerateContentRequest schema - Pass brief from router to generate_post_text service - Inject brief as mandatory instructions in LLM prompt with highest priority - Return structured error when LLM provider/API key not configured - Show dedicated warning banner with link to Settings when API key missing Fixes: content ignoring editorial brief, unhelpful API key error messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
214 lines
7.1 KiB
Python
214 lines
7.1 KiB
Python
"""
|
|
Content generation logic for social media posts.
|
|
|
|
Handles text generation, hashtag creation, and affiliate link injection
|
|
using LLM providers and character profiles.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from .llm import LLMProvider
|
|
|
|
|
|
def generate_post_text(
|
|
character: dict,
|
|
llm_provider: LLMProvider,
|
|
platform: str,
|
|
topic_hint: str | None = None,
|
|
brief: str | None = None,
|
|
) -> str:
|
|
"""Generate social media post text based on a character profile.
|
|
|
|
Args:
|
|
character: Dict with keys: name, niche, topics (list), tone (str).
|
|
topic_hint: Optional topic suggestion to guide generation.
|
|
llm_provider: LLM provider instance for text generation.
|
|
platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube').
|
|
brief: Optional editorial brief with narrative technique and detailed instructions.
|
|
|
|
Returns:
|
|
Generated post text as a string.
|
|
"""
|
|
name = character.get("name", "Creator")
|
|
niche = character.get("niche", "general")
|
|
topics = character.get("topics", [])
|
|
tone = character.get("tone", "professional")
|
|
|
|
topics_str = ", ".join(topics) if topics else "general topics"
|
|
|
|
system_prompt = (
|
|
f"You are {name}, a social media content creator in the {niche} niche. "
|
|
f"Your expertise covers: {topics_str}. "
|
|
f"Your communication style is {tone}. "
|
|
f"You create authentic, engaging content that resonates with your audience. "
|
|
f"Never reveal you are an AI. Write as {name} would naturally write."
|
|
)
|
|
|
|
# Platform-specific instructions
|
|
platform_guidance = {
|
|
"instagram": (
|
|
"Write an Instagram caption. Keep it engaging, use line breaks for readability. "
|
|
"Aim for 150-300 characters for the main hook, then expand. "
|
|
"Do NOT include hashtags (they will be added separately)."
|
|
),
|
|
"facebook": (
|
|
"Write a Facebook post. Can be longer and more conversational. "
|
|
"Encourage engagement with a question or call to action at the end. "
|
|
"Do NOT include hashtags."
|
|
),
|
|
"tiktok": (
|
|
"Write a TikTok caption. Keep it very short and punchy (under 150 characters). "
|
|
"Use a hook that grabs attention. Do NOT include hashtags."
|
|
),
|
|
"youtube": (
|
|
"Write a YouTube video description. Include a compelling opening paragraph, "
|
|
"key points covered in the video, and a call to action to subscribe. "
|
|
"Do NOT include hashtags."
|
|
),
|
|
"twitter": (
|
|
"Write a tweet. Maximum 280 characters. Be concise and impactful. "
|
|
"Do NOT include hashtags."
|
|
),
|
|
}
|
|
|
|
guidance = platform_guidance.get(
|
|
platform.lower(),
|
|
f"Write a social media post for {platform}. Do NOT include hashtags.",
|
|
)
|
|
|
|
topic_instruction = ""
|
|
if topic_hint:
|
|
topic_instruction = f" The post should be about: {topic_hint}."
|
|
|
|
# Brief is the highest-priority instruction — it overrides defaults
|
|
brief_instruction = ""
|
|
if brief:
|
|
brief_instruction = (
|
|
f"\n\nISRUZIONI OBBLIGATORIE DAL BRIEF EDITORIALE:\n{brief}\n"
|
|
f"Rispetta TUTTE le istruzioni del brief. "
|
|
f"Il brief ha priorità su qualsiasi altra indicazione."
|
|
)
|
|
|
|
prompt = (
|
|
f"{guidance}{topic_instruction}{brief_instruction}\n\n"
|
|
f"Write the post now. Output ONLY the post text, nothing else."
|
|
)
|
|
|
|
return llm_provider.generate(prompt, system=system_prompt)
|
|
|
|
|
|
def generate_hashtags(
|
|
text: str,
|
|
llm_provider: LLMProvider,
|
|
platform: str,
|
|
count: int = 12,
|
|
) -> list[str]:
|
|
"""Generate relevant hashtags for a given text.
|
|
|
|
Args:
|
|
text: The post text to generate hashtags for.
|
|
llm_provider: LLM provider instance.
|
|
platform: Target platform.
|
|
count: Number of hashtags to generate.
|
|
|
|
Returns:
|
|
List of hashtag strings (each prefixed with #).
|
|
"""
|
|
platform_limits = {
|
|
"instagram": 30,
|
|
"tiktok": 5,
|
|
"twitter": 3,
|
|
"facebook": 5,
|
|
"youtube": 15,
|
|
}
|
|
max_tags = min(count, platform_limits.get(platform.lower(), count))
|
|
|
|
system_prompt = (
|
|
"You are a social media hashtag strategist. You generate relevant, "
|
|
"effective hashtags that maximize reach and engagement."
|
|
)
|
|
|
|
prompt = (
|
|
f"Generate exactly {max_tags} hashtags for the following {platform} post.\n\n"
|
|
f"Post text:\n{text}\n\n"
|
|
f"Rules:\n"
|
|
f"- Mix popular (high reach) and niche (targeted) hashtags\n"
|
|
f"- Each hashtag must start with #\n"
|
|
f"- No spaces within hashtags, use CamelCase for multi-word\n"
|
|
f"- Output ONLY the hashtags, one per line, nothing else"
|
|
)
|
|
|
|
result = llm_provider.generate(prompt, system=system_prompt)
|
|
|
|
# Parse hashtags from the response
|
|
hashtags: list[str] = []
|
|
for line in result.strip().splitlines():
|
|
tag = line.strip()
|
|
if not tag:
|
|
continue
|
|
# Ensure it starts with #
|
|
if not tag.startswith("#"):
|
|
tag = f"#{tag}"
|
|
# Remove any trailing punctuation or spaces
|
|
tag = tag.split()[0] # Take only the first word if extra text
|
|
hashtags.append(tag)
|
|
|
|
return hashtags[:max_tags]
|
|
|
|
|
|
def inject_affiliate_links(
|
|
text: str,
|
|
affiliate_links: list[dict],
|
|
topics: list[str],
|
|
) -> tuple[str, list[dict]]:
|
|
"""Find relevant affiliate links and append them to the post text.
|
|
|
|
Matches affiliate links based on topic overlap. Links whose keywords
|
|
overlap with the provided topics are appended naturally at the end.
|
|
|
|
Args:
|
|
text: Original post text.
|
|
affiliate_links: List of dicts, each with keys:
|
|
- url (str): The affiliate URL
|
|
- label (str): Display text for the link
|
|
- keywords (list[str]): Topic keywords this link is relevant for
|
|
topics: Current post topics to match against.
|
|
|
|
Returns:
|
|
Tuple of (modified_text, links_used) where links_used is the list
|
|
of affiliate link dicts that were injected.
|
|
"""
|
|
if not affiliate_links or not topics:
|
|
return text, []
|
|
|
|
# Normalize topics to lowercase for matching
|
|
topics_lower = {t.lower() for t in topics}
|
|
|
|
# Score each link by keyword overlap
|
|
scored_links: list[tuple[int, dict]] = []
|
|
for link in affiliate_links:
|
|
keywords = link.get("keywords", [])
|
|
keywords_lower = {k.lower() for k in keywords}
|
|
overlap = len(topics_lower & keywords_lower)
|
|
if overlap > 0:
|
|
scored_links.append((overlap, link))
|
|
|
|
if not scored_links:
|
|
return text, []
|
|
|
|
# Sort by relevance (most overlap first), take top 2
|
|
scored_links.sort(key=lambda x: x[0], reverse=True)
|
|
top_links = [link for _, link in scored_links[:2]]
|
|
|
|
# Build the links section
|
|
links_section_parts: list[str] = []
|
|
for link in top_links:
|
|
label = link.get("label", "Check this out")
|
|
url = link.get("url", "")
|
|
links_section_parts.append(f"{label}: {url}")
|
|
|
|
links_text = "\n".join(links_section_parts)
|
|
modified_text = f"{text}\n\n{links_text}"
|
|
|
|
return modified_text, top_links
|