Files
social-media-automation/app/services/content_generator.py
Consultoría AS 11b0ba46fa feat: Add Content Generation Engine v2 with quality scoring
Major improvements to AI content generation:

## New Components (app/services/ai/)
- PromptLibrary: YAML-based prompt templates with inheritance
- ContextEngine: Anti-repetition and best performers tracking
- ContentGeneratorV2: Enhanced generation with dynamic parameters
- PlatformAdapter: Platform-specific content adaptation
- ContentValidator: AI-powered quality scoring (0-100)

## Prompt Library (app/prompts/)
- 3 personalities: default, educational, promotional
- 5 templates: tip_tech, product_post, service_post, thread, response
- 4 platform configs: x, threads, instagram, facebook
- Few-shot examples by category: ia, productividad, seguridad

## Database Changes
- New table: content_memory (tracks generated content)
- New columns in posts: quality_score, score_breakdown, generation_attempts

## New API Endpoints (/api/v2/generate/)
- POST /generate - Generation with quality check
- POST /generate/batch - Batch generation
- POST /quality/evaluate - Evaluate content quality
- GET /templates, /personalities, /platforms - List configs

## Celery Tasks
- update_engagement_scores (every 6h)
- cleanup_old_memory (monthly)
- refresh_best_posts_yaml (weekly)

## Tests
- Comprehensive tests for all AI engine components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:55:28 +00:00

716 lines
22 KiB
Python

"""
Servicio de generación de contenido con DeepSeek API.
Este archivo mantiene la interfaz original (ContentGenerator) para
compatibilidad con código existente, pero internamente usa el nuevo
motor modular (ContentGeneratorV2) cuando está disponible.
Para nuevas integraciones, usar directamente:
from app.services.ai import ContentGeneratorV2, content_generator_v2
"""
import json
from typing import Optional, List, Dict, Any
from openai import OpenAI
from sqlalchemy.orm import Session
from app.core.config import settings
# Importar nuevo motor
try:
from app.services.ai import (
ContentGeneratorV2,
content_generator_v2,
ContextEngine,
context_engine,
ContentValidator,
content_validator,
)
NEW_ENGINE_AVAILABLE = True
except ImportError:
NEW_ENGINE_AVAILABLE = False
class ContentGenerator:
"""
Generador de contenido usando DeepSeek API.
Esta clase mantiene la interfaz original para compatibilidad.
Internamente delega al nuevo motor cuando está disponible.
Para nuevas funcionalidades, usar ContentGeneratorV2 directamente.
"""
def __init__(self, use_new_engine: bool = True):
"""
Inicializar el generador.
Args:
use_new_engine: Si usar el nuevo motor v2 (default: True)
"""
self._client = None
self.model = "deepseek-chat"
self._use_new_engine = use_new_engine and NEW_ENGINE_AVAILABLE
if self._use_new_engine:
self._v2 = content_generator_v2
self._validator = content_validator
self._context = context_engine
else:
self._v2 = None
self._validator = None
self._context = None
@property
def client(self):
"""Lazy initialization del cliente OpenAI."""
if self._client is None:
if not settings.DEEPSEEK_API_KEY:
raise ValueError("DEEPSEEK_API_KEY no configurada. Configura la variable de entorno.")
self._client = OpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url=settings.DEEPSEEK_BASE_URL
)
return self._client
def _get_system_prompt(self) -> str:
"""Obtener el prompt del sistema con la personalidad de la marca."""
# Si hay nuevo motor, usar su prompt
if self._use_new_engine:
try:
from app.services.ai import prompt_library
return prompt_library.get_system_prompt()
except Exception:
pass
# Fallback al prompt original
return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}.
SOBRE LA EMPRESA:
- Especializada en soluciones de IA, automatización y transformación digital
- Vende equipos de cómputo e impresoras 3D
- Sitio web: {settings.BUSINESS_WEBSITE}
TONO DE COMUNICACIÓN:
{settings.CONTENT_TONE}
ESTILO (inspirado en @midudev, @MoureDev, @SoyDalto):
- Tips cortos y accionables
- Contenido educativo de valor
- Cercano pero profesional
- Uso moderado de emojis
- Hashtags relevantes (máximo 3-5)
REGLAS:
- Nunca uses lenguaje ofensivo
- No hagas promesas exageradas
- Sé honesto y transparente
- Enfócate en ayudar, no en vender directamente
- Adapta el contenido a cada plataforma"""
# === Métodos principales con nuevo motor ===
async def generate_tip_tech(
self,
category: str,
platform: str,
template: Optional[str] = None,
db: Optional[Session] = None,
validate: bool = True
) -> str:
"""
Generar un tip tech.
Args:
category: Categoría del tip
platform: Plataforma destino
template: Template opcional (ignorado en v2)
db: Sesión de DB para context engine
validate: Si validar el contenido generado
Returns:
Contenido del tip
"""
if self._use_new_engine:
result = await self._v2.generate_tip(
category=category,
platform=platform,
db=db
)
content = result["adapted_content"]
# Validar y regenerar si es necesario
if validate and self._validator:
quality = await self._validator.evaluate(content, platform)
if quality.final_decision == "regenerate":
# Regenerar con hints
hints = self._validator.get_regeneration_hints(quality)
result = await self._v2.generate(
template_name="tip_tech",
variables={
"category": category,
"difficulty_level": "principiante",
"target_audience": "profesionales tech"
},
platform=platform,
db=db,
temperature_override=0.9 # Más creatividad en retry
)
content = result["adapted_content"]
return content
# Fallback a implementación original
return await self._generate_tip_tech_legacy(category, platform, template)
async def _generate_tip_tech_legacy(
self,
category: str,
platform: str,
template: Optional[str] = None
) -> str:
"""Implementación original de generate_tip_tech."""
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 500
}
prompt = f"""Genera un tip de tecnología para la categoría: {category}
PLATAFORMA: {platform}
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
{f'USA ESTE TEMPLATE COMO BASE: {template}' if template else ''}
REQUISITOS:
- Tip práctico y accionable
- Fácil de entender
- Incluye un emoji relevante al inicio
- Termina con 2-3 hashtags relevantes
- NO incluyas enlaces
Responde SOLO con el texto del post, sin explicaciones."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=300,
temperature=0.7
)
return response.choices[0].message.content.strip()
async def generate_product_post(
self,
product: Dict,
platform: str,
db: Optional[Session] = None,
validate: bool = True
) -> str:
"""
Generar post para un producto.
Args:
product: Dict con datos del producto
platform: Plataforma destino
db: Sesión de DB
validate: Si validar contenido
Returns:
Contenido del post
"""
if self._use_new_engine:
result = await self._v2.generate_product_post(
product=product,
platform=platform,
db=db
)
content = result["adapted_content"]
if validate and self._validator:
quality = await self._validator.evaluate(content, platform)
if quality.final_decision == "regenerate":
result = await self._v2.generate_product_post(
product=product,
platform=platform,
db=db,
temperature_override=0.9
)
content = result["adapted_content"]
return content
# Fallback a implementación original
return await self._generate_product_post_legacy(product, platform)
async def _generate_product_post_legacy(
self,
product: Dict,
platform: str
) -> str:
"""Implementación original de generate_product_post."""
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 1000
}
prompt = f"""Genera un post promocional para este producto:
PRODUCTO: {product['name']}
DESCRIPCIÓN: {product.get('description', 'N/A')}
PRECIO: ${product['price']:,.2f} MXN
CATEGORÍA: {product['category']}
ESPECIFICACIONES: {json.dumps(product.get('specs', {}), ensure_ascii=False)}
PUNTOS DESTACADOS: {', '.join(product.get('highlights', []))}
PLATAFORMA: {platform}
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
REQUISITOS:
- Destaca los beneficios principales
- Incluye el precio
- Usa emojis relevantes
- Incluye CTA sutil (ej: "Contáctanos", "Más info en DM")
- Termina con 2-3 hashtags
- NO inventes especificaciones
Responde SOLO con el texto del post."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=400,
temperature=0.7
)
return response.choices[0].message.content.strip()
async def generate_service_post(
self,
service: Dict,
platform: str,
db: Optional[Session] = None,
validate: bool = True
) -> str:
"""
Generar post para un servicio.
Args:
service: Dict con datos del servicio
platform: Plataforma destino
db: Sesión de DB
validate: Si validar contenido
Returns:
Contenido del post
"""
if self._use_new_engine:
result = await self._v2.generate_service_post(
service=service,
platform=platform,
db=db
)
content = result["adapted_content"]
if validate and self._validator:
quality = await self._validator.evaluate(content, platform)
if quality.final_decision == "regenerate":
result = await self._v2.generate_service_post(
service=service,
platform=platform,
db=db,
temperature_override=0.9
)
content = result["adapted_content"]
return content
# Fallback
return await self._generate_service_post_legacy(service, platform)
async def _generate_service_post_legacy(
self,
service: Dict,
platform: str
) -> str:
"""Implementación original de generate_service_post."""
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 1000
}
prompt = f"""Genera un post promocional para este servicio:
SERVICIO: {service['name']}
DESCRIPCIÓN: {service.get('description', 'N/A')}
CATEGORÍA: {service['category']}
SECTORES OBJETIVO: {', '.join(service.get('target_sectors', []))}
BENEFICIOS: {', '.join(service.get('benefits', []))}
CTA: {service.get('call_to_action', 'Contáctanos para más información')}
PLATAFORMA: {platform}
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
REQUISITOS:
- Enfócate en el problema que resuelve
- Destaca 2-3 beneficios clave
- Usa emojis relevantes (✅, 🚀, 💡)
- Incluye el CTA
- Termina con 2-3 hashtags
- Tono consultivo, no vendedor
Responde SOLO con el texto del post."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=400,
temperature=0.7
)
return response.choices[0].message.content.strip()
async def generate_thread(
self,
topic: str,
num_posts: int = 5,
db: Optional[Session] = None
) -> List[str]:
"""
Generar un hilo educativo.
Args:
topic: Tema del hilo
num_posts: Número de posts
db: Sesión de DB
Returns:
Lista de posts del hilo
"""
if self._use_new_engine:
return await self._v2.generate_thread(
topic=topic,
num_posts=num_posts,
db=db
)
# Fallback
return await self._generate_thread_legacy(topic, num_posts)
async def _generate_thread_legacy(
self,
topic: str,
num_posts: int = 5
) -> List[str]:
"""Implementación original de generate_thread."""
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
REQUISITOS:
- Post 1: Gancho que capture atención
- Posts 2-{num_posts-1}: Contenido educativo de valor
- Post {num_posts}: Conclusión con CTA
FORMATO:
- Cada post máximo 280 caracteres
- Numera cada post (1/, 2/, etc.)
- Usa emojis relevantes
- El último post incluye hashtags
Responde con cada post en una línea separada, sin explicaciones."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=1500,
temperature=0.7
)
content = response.choices[0].message.content.strip()
posts = [p.strip() for p in content.split('\n') if p.strip()]
return posts
async def generate_response_suggestion(
self,
interaction_content: str,
interaction_type: str,
context: Optional[str] = None
) -> List[str]:
"""
Generar sugerencias de respuesta para una interacción.
Args:
interaction_content: Contenido de la interacción
interaction_type: Tipo de interacción
context: Contexto adicional
Returns:
Lista de 3 opciones de respuesta
"""
if self._use_new_engine:
return await self._v2.generate_response(
interaction_content=interaction_content,
interaction_type=interaction_type,
context=context
)
# Fallback
return await self._generate_response_legacy(
interaction_content, interaction_type, context
)
async def _generate_response_legacy(
self,
interaction_content: str,
interaction_type: str,
context: Optional[str] = None
) -> List[str]:
"""Implementación original de generate_response_suggestion."""
prompt = f"""Un usuario escribió esto en redes sociales:
"{interaction_content}"
TIPO DE INTERACCIÓN: {interaction_type}
{f'CONTEXTO ADICIONAL: {context}' if context else ''}
Genera 3 opciones de respuesta diferentes:
1. Respuesta corta y amigable
2. Respuesta que invite a continuar la conversación
3. Respuesta que dirija a más información/contacto
REQUISITOS:
- Máximo 280 caracteres cada una
- Tono amigable y profesional
- Si es una queja, sé empático
- Si es una pregunta técnica, sé útil
Responde con las 3 opciones numeradas, una por línea."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=500,
temperature=0.8
)
content = response.choices[0].message.content.strip()
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
cleaned = []
for s in suggestions:
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
s = s[2:].strip()
cleaned.append(s)
return cleaned[:3]
async def adapt_content_for_platform(
self,
content: str,
target_platform: str
) -> str:
"""
Adaptar contenido existente a una plataforma específica.
Args:
content: Contenido original
target_platform: Plataforma destino
Returns:
Contenido adaptado
"""
if self._use_new_engine:
# Detectar plataforma de origen (asumimos la más genérica)
return await self._v2.adapt_content(
content=content,
source_platform="instagram", # Asume origen genérico
target_platform=target_platform
)
# Fallback
return await self._adapt_content_legacy(content, target_platform)
async def _adapt_content_legacy(
self,
content: str,
target_platform: str
) -> str:
"""Implementación original de adapt_content_for_platform."""
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 1000
}
prompt = f"""Adapta este contenido para {target_platform}:
CONTENIDO ORIGINAL:
{content}
LÍMITE DE CARACTERES: {char_limits.get(target_platform, 500)}
REQUISITOS PARA {target_platform.upper()}:
{"- Muy conciso, directo al punto" if target_platform == "x" else ""}
{"- Puede ser más extenso, incluir más contexto" if target_platform == "instagram" else ""}
{"- Tono más casual y cercano" if target_platform == "threads" else ""}
{"- Puede incluir links, más profesional" if target_platform == "facebook" else ""}
- Mantén la esencia del mensaje
- Ajusta hashtags según la plataforma
Responde SOLO con el contenido adaptado."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self._get_system_prompt()},
{"role": "user", "content": prompt}
],
max_tokens=400,
temperature=0.6
)
return response.choices[0].message.content.strip()
# === Nuevos métodos (solo v2) ===
async def generate_with_quality_check(
self,
template_name: str,
variables: Dict[str, Any],
platform: str,
db: Optional[Session] = None,
max_attempts: int = 2
) -> Dict[str, Any]:
"""
Generar contenido con validación y regeneración automática.
Solo disponible con el nuevo motor.
Args:
template_name: Nombre del template
variables: Variables para el template
platform: Plataforma destino
db: Sesión de DB
max_attempts: Máximo intentos de regeneración
Returns:
Dict con contenido, score, y metadata
"""
if not self._use_new_engine:
raise RuntimeError(
"Este método requiere el nuevo motor. "
"Asegúrate de que app.services.ai esté disponible."
)
attempt = 0
temperature = 0.7
while attempt < max_attempts:
attempt += 1
# Generar
result = await self._v2.generate(
template_name=template_name,
variables=variables,
platform=platform,
db=db,
temperature_override=temperature
)
content = result["adapted_content"]
# Evaluar calidad
quality = await self._validator.evaluate(content, platform)
# Si pasa, retornar
if quality.final_decision == "accept":
return {
"content": content,
"quality_score": quality.scoring.total_score if quality.scoring else None,
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
"is_top_performer": quality.scoring.is_top_performer if quality.scoring else False,
"attempts": attempt,
"metadata": result["metadata"]
}
# Si debe regenerar, aumentar temperature
temperature = min(1.0, temperature + 0.1)
# Si llegamos aquí, usar el último intento aunque no sea ideal
return {
"content": content,
"quality_score": quality.scoring.total_score if quality.scoring else None,
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
"is_top_performer": False,
"attempts": attempt,
"metadata": result["metadata"],
"warning": "Contenido aceptado después de máximos intentos"
}
async def save_to_memory(
self,
db: Session,
post_id: int,
content: str,
content_type: str,
platform: str,
quality_score: Optional[int] = None,
quality_breakdown: Optional[Dict] = None
):
"""
Guardar contenido en memoria para tracking.
Solo disponible con el nuevo motor.
Args:
db: Sesión de DB
post_id: ID del post
content: Contenido generado
content_type: Tipo de contenido
platform: Plataforma
quality_score: Score de calidad
quality_breakdown: Breakdown del score
"""
if not self._use_new_engine:
return # Silenciosamente ignorar si no hay nuevo motor
self._context.save_to_memory(
db=db,
post_id=post_id,
content=content,
content_type=content_type,
platform=platform,
quality_score=quality_score,
quality_breakdown=quality_breakdown
)
# Instancia global
content_generator = ContentGenerator()