Files
leopost-full/backend/app/services/content.py
Michele 16c7c4404c feat: Phase B learning + hashtag profiles Pro-only lock
- Approve action saves post as reference example in character's content_rules
- Keep last 5 approved examples per character (auto-rotating)
- Inject last 3 approved examples as few-shot in LLM system prompt
- Lock YouTube/TikTok hashtag profile tabs for Freemium users (Pro only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:48:04 +02:00

268 lines
9.5 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),
and optional rich profile: brand_voice, target_audience,
business_goals, products_services, content_rules.
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"
# Base identity
system_parts = [
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}.",
]
# Rich profile: brand voice
brand_voice = character.get("brand_voice")
if brand_voice:
system_parts.append(f"\nVOCE E STILE DI COMUNICAZIONE:\n{brand_voice}")
# Rich profile: target audience
target_audience = character.get("target_audience")
if target_audience:
system_parts.append(f"\nPUBBLICO TARGET:\n{target_audience}")
# Rich profile: business goals
business_goals = character.get("business_goals")
if business_goals:
system_parts.append(f"\nOBIETTIVI BUSINESS:\n{business_goals}")
# Rich profile: products/services
products_services = character.get("products_services")
if products_services:
system_parts.append(f"\nPRODOTTI/SERVIZI OFFERTI:\n{products_services}")
# Rich profile: content rules (do/don't)
content_rules = character.get("content_rules") or {}
do_rules = content_rules.get("do", [])
dont_rules = content_rules.get("dont", [])
if do_rules or dont_rules:
rules_text = "\nREGOLE CONTENUTI:"
if do_rules:
rules_text += "\nFA SEMPRE: " + " | ".join(do_rules)
if dont_rules:
rules_text += "\nNON FARE MAI: " + " | ".join(dont_rules)
system_parts.append(rules_text)
# Few-shot: approved examples from past posts
approved_examples = content_rules.get("approved_examples", [])
if approved_examples:
examples_text = "\nESEMPI DI POST APPROVATI (usa come riferimento per stile e tono):"
for i, ex in enumerate(approved_examples[-3:], 1): # last 3 in prompt
plat = ex.get("platform", "")
examples_text += f"\n--- Esempio {i} ({plat}) ---\n{ex.get('text', '')}"
system_parts.append(examples_text)
system_parts.extend([
"\nYou create authentic, engaging content that resonates with your audience.",
"Never reveal you are an AI. Write as {name} would naturally write.",
"\nREGOLA CRITICA: Se ti viene indicata una tecnica narrativa (PAS, AIDA, Storytelling, ecc.), "
"usala SOLO come struttura invisibile del testo. "
"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', "
"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). "
"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo.",
])
system_prompt = "\n".join(system_parts)
# 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