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>
This commit is contained in:
2026-01-28 20:55:28 +00:00
parent f458f809ca
commit 11b0ba46fa
36 changed files with 6266 additions and 55 deletions

View File

@@ -0,0 +1,24 @@
"""
AI Services - Content Generation Engine v2.0
Este módulo contiene los componentes del motor de generación de contenido:
- PromptLibrary: Carga y renderiza prompts desde YAML
- ContextEngine: Anti-repetición y selección de best performers
- ContentGeneratorV2: Interfaz mejorada con DeepSeek
- PlatformAdapter: Adapta contenido por plataforma
- ContentValidator: Validación y scoring con IA
"""
from app.services.ai.prompt_library import PromptLibrary
from app.services.ai.context_engine import ContextEngine
from app.services.ai.generator import ContentGeneratorV2
from app.services.ai.platform_adapter import PlatformAdapter
from app.services.ai.validator import ContentValidator
__all__ = [
"PromptLibrary",
"ContextEngine",
"ContentGeneratorV2",
"PlatformAdapter",
"ContentValidator",
]

View File

@@ -0,0 +1,519 @@
"""
ContextEngine - Motor de contexto para generación inteligente.
Este módulo maneja:
- Anti-repetición de temas y frases
- Selección de best performers para few-shot learning
- Ventana de memoria de posts recientes
- Análisis semántico de contenido
"""
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any, Tuple
from sqlalchemy import func, desc
from sqlalchemy.orm import Session
from app.models.content_memory import ContentMemory
from app.models.post import Post
class ContextEngine:
"""
Motor de contexto para generación de contenido.
Responsabilidades:
1. Rastrear contenido generado para evitar repeticiones
2. Identificar y usar posts exitosos como ejemplos
3. Sugerir variaciones de hooks y estilos
"""
def __init__(
self,
memory_window: int = 50,
topic_cooldown_days: int = 7,
phrase_cooldown_days: int = 14,
top_percentile: int = 20
):
"""
Inicializar el Context Engine.
Args:
memory_window: Cantidad de posts recientes a recordar
topic_cooldown_days: Días antes de repetir un tema
phrase_cooldown_days: Días antes de repetir una frase distintiva
top_percentile: Percentil para considerar "top performer"
"""
self.memory_window = memory_window
self.topic_cooldown_days = topic_cooldown_days
self.phrase_cooldown_days = phrase_cooldown_days
self.top_percentile = top_percentile
# === Anti-Repetición ===
def get_recent_topics(
self,
db: Session,
days: Optional[int] = None,
limit: int = 100
) -> List[str]:
"""
Obtener temas usados recientemente.
Args:
db: Sesión de base de datos
days: Días hacia atrás (default: topic_cooldown_days)
limit: Máximo de registros a consultar
Returns:
Lista de temas usados recientemente
"""
if days is None:
days = self.topic_cooldown_days
since = datetime.utcnow() - timedelta(days=days)
memories = db.query(ContentMemory).filter(
ContentMemory.created_at >= since,
ContentMemory.topics.isnot(None)
).order_by(desc(ContentMemory.created_at)).limit(limit).all()
# Flatten y contar frecuencia
all_topics = []
for mem in memories:
if mem.topics:
all_topics.extend(mem.topics)
return list(set(all_topics))
def get_recent_phrases(
self,
db: Session,
days: Optional[int] = None,
limit: int = 100
) -> List[str]:
"""
Obtener frases distintivas usadas recientemente.
Args:
db: Sesión de base de datos
days: Días hacia atrás (default: phrase_cooldown_days)
limit: Máximo de registros a consultar
Returns:
Lista de frases usadas recientemente
"""
if days is None:
days = self.phrase_cooldown_days
since = datetime.utcnow() - timedelta(days=days)
memories = db.query(ContentMemory).filter(
ContentMemory.created_at >= since,
ContentMemory.key_phrases.isnot(None)
).order_by(desc(ContentMemory.created_at)).limit(limit).all()
all_phrases = []
for mem in memories:
if mem.key_phrases:
all_phrases.extend(mem.key_phrases)
return list(set(all_phrases))
def get_recent_hooks(
self,
db: Session,
days: int = 14,
limit: int = 50
) -> Dict[str, int]:
"""
Obtener tipos de hooks usados recientemente con frecuencia.
Args:
db: Sesión de base de datos
days: Días hacia atrás
limit: Máximo de registros
Returns:
Dict de hook_type -> count
"""
since = datetime.utcnow() - timedelta(days=days)
memories = db.query(ContentMemory).filter(
ContentMemory.created_at >= since,
ContentMemory.hook_type.isnot(None)
).order_by(desc(ContentMemory.created_at)).limit(limit).all()
hook_counts: Dict[str, int] = {}
for mem in memories:
hook_counts[mem.hook_type] = hook_counts.get(mem.hook_type, 0) + 1
return hook_counts
def suggest_hook_type(
self,
db: Session,
preferred_hooks: Optional[List[str]] = None
) -> str:
"""
Sugerir un tipo de hook basado en lo menos usado recientemente.
Args:
db: Sesión de base de datos
preferred_hooks: Lista de hooks preferidos para esta plataforma
Returns:
Tipo de hook sugerido
"""
recent_hooks = self.get_recent_hooks(db)
if not preferred_hooks:
preferred_hooks = [
"pregunta_retórica",
"dato_impactante",
"tip_directo",
"afirmación_bold",
"historia_corta"
]
# Encontrar el hook menos usado
min_count = float("inf")
suggested = preferred_hooks[0]
for hook in preferred_hooks:
count = recent_hooks.get(hook, 0)
if count < min_count:
min_count = count
suggested = hook
return suggested
def build_exclusion_context(
self,
db: Session,
content_type: str,
category: Optional[str] = None
) -> str:
"""
Construir contexto de exclusión para el prompt.
Args:
db: Sesión de base de datos
content_type: Tipo de contenido (tip_tech, product_post, etc.)
category: Categoría específica (ia, productividad, etc.)
Returns:
String con instrucciones de exclusión para el prompt
"""
recent_topics = self.get_recent_topics(db)
recent_phrases = self.get_recent_phrases(db)
exclusions = []
if recent_topics:
topics_str = ", ".join(recent_topics[:10])
exclusions.append(f"TEMAS YA CUBIERTOS RECIENTEMENTE (evitar): {topics_str}")
if recent_phrases:
phrases_str = "; ".join(recent_phrases[:5])
exclusions.append(f"FRASES YA USADAS (no repetir): {phrases_str}")
# Sugerir hook menos usado
suggested_hook = self.suggest_hook_type(db)
exclusions.append(f"HOOK SUGERIDO: {suggested_hook} (poco usado recientemente)")
if exclusions:
return "\n".join(exclusions)
return ""
# === Best Performers ===
def get_top_performers(
self,
db: Session,
content_type: Optional[str] = None,
platform: Optional[str] = None,
limit: int = 5
) -> List[ContentMemory]:
"""
Obtener posts con mejor rendimiento.
Args:
db: Sesión de base de datos
content_type: Filtrar por tipo de contenido
platform: Filtrar por plataforma
limit: Máximo de resultados
Returns:
Lista de ContentMemory de top performers
"""
query = db.query(ContentMemory).filter(
ContentMemory.is_top_performer == True,
ContentMemory.engagement_score.isnot(None)
)
if content_type:
query = query.filter(ContentMemory.content_type == content_type)
if platform:
query = query.filter(ContentMemory.platform == platform)
# Ordenar por score y limitar uso excesivo
return query.order_by(
desc(ContentMemory.engagement_score),
ContentMemory.times_used_as_example # Preferir menos usados
).limit(limit).all()
def get_few_shot_examples(
self,
db: Session,
content_type: str,
platform: Optional[str] = None,
min_examples: int = 2,
max_examples: int = 5
) -> List[str]:
"""
Obtener ejemplos de contenido real para few-shot prompting.
Args:
db: Sesión de base de datos
content_type: Tipo de contenido
platform: Plataforma específica
min_examples: Mínimo de ejemplos
max_examples: Máximo de ejemplos
Returns:
Lista de contenidos de posts exitosos
"""
top_performers = self.get_top_performers(
db, content_type, platform, limit=max_examples
)
examples = []
for mem in top_performers:
# Obtener contenido del post original
post = db.query(Post).filter(Post.id == mem.post_id).first()
if post:
content = post.get_content_for_platform(platform or "x")
examples.append(content)
# Registrar uso como ejemplo
mem.record_example_usage()
db.commit()
return examples[:max_examples]
def build_few_shot_context(
self,
db: Session,
content_type: str,
platform: Optional[str] = None
) -> str:
"""
Construir contexto de few-shot para el prompt.
Args:
db: Sesión de base de datos
content_type: Tipo de contenido
platform: Plataforma
Returns:
String con ejemplos formateados para el prompt
"""
examples = self.get_few_shot_examples(db, content_type, platform)
if not examples:
return ""
formatted = ["EJEMPLOS DE POSTS EXITOSOS (inspírate en el estilo):"]
for i, ex in enumerate(examples, 1):
formatted.append(f"\n--- Ejemplo {i} ---\n{ex}")
return "\n".join(formatted)
# === Análisis de Contenido ===
def analyze_content(
self,
content: str,
content_type: str,
platform: str
) -> Dict[str, Any]:
"""
Analizar contenido generado para almacenar en memoria.
Este es un análisis básico basado en reglas.
Para análisis más sofisticado, usar el Validator con IA.
Args:
content: Contenido a analizar
content_type: Tipo de contenido
platform: Plataforma destino
Returns:
Dict con análisis (topics, key_phrases, hook_type)
"""
# Detectar hook type basado en primera línea
first_line = content.split("\n")[0].strip()
hook_type = self._detect_hook_type(first_line)
# Extraer posibles temas (simplificado)
topics = self._extract_topics(content)
# Extraer frases distintivas
key_phrases = self._extract_key_phrases(content)
return {
"hook_type": hook_type,
"topics": topics,
"key_phrases": key_phrases,
"content_summary": content[:200], # Resumen simple
}
def _detect_hook_type(self, first_line: str) -> str:
"""Detectar tipo de hook basado en la primera línea."""
first_line_lower = first_line.lower()
if first_line.endswith("?"):
return "pregunta_retórica"
elif any(char.isdigit() for char in first_line) and "%" in first_line:
return "dato_impactante"
elif first_line_lower.startswith(("tip:", "consejo:", "truco:")):
return "tip_directo"
elif "🧵" in first_line or "hilo" in first_line_lower:
return "hilo_intro"
elif any(word in first_line_lower for word in ["nunca", "siempre", "error", "mito"]):
return "afirmación_bold"
else:
return "general"
def _extract_topics(self, content: str) -> List[str]:
"""Extraer temas del contenido (basado en keywords)."""
content_lower = content.lower()
topic_keywords = {
"ia": ["ia", "inteligencia artificial", "chatgpt", "claude", "deepseek", "llm"],
"productividad": ["productividad", "tiempo", "eficiencia", "organización", "tareas"],
"python": ["python", "django", "flask", "pip"],
"seguridad": ["seguridad", "password", "contraseña", "phishing", "privacidad"],
"automatización": ["automatización", "automatizar", "script", "workflow"],
"hardware": ["laptop", "computadora", "impresora", "monitor", "teclado"],
}
found_topics = []
for topic, keywords in topic_keywords.items():
if any(kw in content_lower for kw in keywords):
found_topics.append(topic)
return found_topics
def _extract_key_phrases(self, content: str) -> List[str]:
"""Extraer frases distintivas del contenido."""
# Buscar patrones como "la regla X", "el método Y", etc.
phrases = []
import re
# Patrones comunes de frases distintivas
patterns = [
r"la regla [\w\-]+",
r"el método [\w\-]+",
r"el truco [\w\-]+",
r"la técnica [\w\-]+",
r"\d+[%] [\w\s]{5,20}", # "90% de developers..."
]
for pattern in patterns:
matches = re.findall(pattern, content.lower())
phrases.extend(matches)
return list(set(phrases))[:5] # Max 5 frases
# === Persistencia ===
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,
template_used: Optional[str] = None,
personality_used: Optional[str] = None
) -> ContentMemory:
"""
Guardar contenido en memoria para tracking.
Args:
db: Sesión de base de datos
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
template_used: Template usado
personality_used: Personalidad usada
Returns:
ContentMemory creado
"""
# Analizar contenido
analysis = self.analyze_content(content, content_type, platform)
memory = ContentMemory(
post_id=post_id,
topics=analysis["topics"],
key_phrases=analysis["key_phrases"],
hook_type=analysis["hook_type"],
content_summary=analysis["content_summary"],
quality_score=quality_score,
quality_breakdown=quality_breakdown,
platform=platform,
content_type=content_type,
template_used=template_used,
personality_used=personality_used,
)
db.add(memory)
db.commit()
db.refresh(memory)
return memory
def update_engagement_scores(self, db: Session) -> int:
"""
Actualizar scores de engagement y marcar top performers.
Debe ejecutarse periódicamente (ej: tarea Celery diaria).
Returns:
Número de registros actualizados
"""
# Calcular percentil para top performer
all_scores = db.query(ContentMemory.engagement_score).filter(
ContentMemory.engagement_score.isnot(None)
).all()
if not all_scores:
return 0
scores = sorted([s[0] for s in all_scores], reverse=True)
threshold_idx = max(0, int(len(scores) * self.top_percentile / 100) - 1)
threshold_score = scores[threshold_idx]
# Marcar top performers
updated = db.query(ContentMemory).filter(
ContentMemory.engagement_score >= threshold_score,
ContentMemory.is_top_performer == False
).update({"is_top_performer": True})
db.commit()
return updated
# Instancia global con configuración por defecto
context_engine = ContextEngine()

View File

@@ -0,0 +1,493 @@
"""
ContentGeneratorV2 - Generador de contenido mejorado con DeepSeek.
Este módulo maneja:
- Generación de contenido usando prompts de la biblioteca
- Integración con Context Engine para anti-repetición
- Soporte para few-shot learning
- Parámetros dinámicos por tipo de contenido
"""
import json
from typing import Optional, List, Dict, Any
from openai import OpenAI
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.ai.prompt_library import prompt_library, PromptLibrary
from app.services.ai.context_engine import context_engine, ContextEngine
from app.services.ai.platform_adapter import platform_adapter, PlatformAdapter
class ContentGeneratorV2:
"""
Generador de contenido v2.0 con motor modular.
Mejoras sobre v1:
- Prompts externalizados en YAML
- Anti-repetición via Context Engine
- Few-shot learning con posts exitosos
- Adaptación automática por plataforma
"""
def __init__(
self,
prompt_lib: Optional[PromptLibrary] = None,
ctx_engine: Optional[ContextEngine] = None,
plt_adapter: Optional[PlatformAdapter] = None
):
"""
Inicializar el generador.
Args:
prompt_lib: PromptLibrary (usa global si no se especifica)
ctx_engine: ContextEngine (usa global si no se especifica)
plt_adapter: PlatformAdapter (usa global si no se especifica)
"""
self._client = None
self.model = "deepseek-chat"
self.prompt_lib = prompt_lib or prompt_library
self.ctx_engine = ctx_engine or context_engine
self.plt_adapter = plt_adapter or platform_adapter
@property
def client(self) -> OpenAI:
"""Lazy initialization del cliente OpenAI/DeepSeek."""
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
# === Generación Principal ===
async def generate(
self,
template_name: str,
variables: Dict[str, Any],
platform: str,
db: Optional[Session] = None,
use_context: bool = True,
use_few_shot: bool = True,
personality: Optional[str] = None,
temperature_override: Optional[float] = None,
) -> Dict[str, Any]:
"""
Generar contenido usando un template.
Args:
template_name: Nombre del template (tip_tech, product_post, etc.)
variables: Variables para el template
platform: Plataforma destino
db: Sesión de DB (requerida para context)
use_context: Si usar anti-repetición
use_few_shot: Si usar ejemplos de posts exitosos
personality: Override de personalidad
temperature_override: Override de temperature
Returns:
Dict con:
- content: Contenido generado
- adapted_content: Contenido adaptado a plataforma
- metadata: Info de generación
"""
# 1. Renderizar template
rendered = self.prompt_lib.render_template(
template_name,
variables,
personality or "default"
)
system_prompt = rendered["system_prompt"]
user_prompt = rendered["user_prompt"]
params = rendered["parameters"]
# 2. Agregar contexto de exclusión si hay DB
if db and use_context:
exclusion_context = self.ctx_engine.build_exclusion_context(
db,
template_name,
variables.get("category")
)
if exclusion_context:
user_prompt += f"\n\n{exclusion_context}"
# 3. Agregar few-shot examples si hay DB
if db and use_few_shot:
few_shot_context = self.ctx_engine.build_few_shot_context(
db,
template_name,
platform
)
if few_shot_context:
user_prompt += f"\n\n{few_shot_context}"
# También agregar ejemplos del YAML si hay categoría
category = variables.get("category")
if category:
yaml_examples = self.prompt_lib.get_few_shot_examples(
template_name,
category,
max_examples=2
)
if yaml_examples:
user_prompt += "\n\nEJEMPLOS DE REFERENCIA:\n"
for ex in yaml_examples:
user_prompt += f"\n---\n{ex}\n---"
# 4. Agregar límites de plataforma
limits = self.plt_adapter.get_limits(platform)
user_prompt += f"\n\nPLATAFORMA: {platform}"
user_prompt += f"\nLÍMITE DE CARACTERES: {limits.get('max_characters', 500)}"
user_prompt += f"\nMÁXIMO HASHTAGS: {limits.get('max_hashtags', 5)}"
# 5. Llamar a DeepSeek
temperature = temperature_override or params.get("temperature", 0.7)
max_tokens = params.get("max_tokens", 400)
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=max_tokens,
temperature=temperature
)
content = response.choices[0].message.content.strip()
# 6. Adaptar a plataforma
adapted = self.plt_adapter.adapt(content, platform)
return {
"content": content,
"adapted_content": adapted.content,
"truncated": adapted.truncated,
"metadata": {
"template": template_name,
"personality": rendered["personality"],
"platform": platform,
"temperature": temperature,
"tokens_used": response.usage.total_tokens if response.usage else None,
"changes_made": adapted.changes_made,
}
}
# === Métodos de Conveniencia ===
async def generate_tip(
self,
category: str,
platform: str,
db: Optional[Session] = None,
difficulty: str = "principiante",
**kwargs
) -> Dict[str, Any]:
"""
Generar un tip de tecnología.
Args:
category: Categoría del tip (ia, productividad, seguridad, etc.)
platform: Plataforma destino
db: Sesión de DB
difficulty: Nivel de dificultad
**kwargs: Argumentos adicionales para generate()
Returns:
Dict con contenido generado
"""
return await self.generate(
template_name="tip_tech",
variables={
"category": category,
"difficulty_level": difficulty,
"target_audience": "profesionales tech"
},
platform=platform,
db=db,
**kwargs
)
async def generate_product_post(
self,
product: Dict[str, Any],
platform: str,
db: Optional[Session] = None,
**kwargs
) -> Dict[str, Any]:
"""
Generar post para un producto.
Args:
product: Dict con datos del producto
platform: Plataforma destino
db: Sesión de DB
**kwargs: Argumentos adicionales
Returns:
Dict con contenido generado
"""
return await self.generate(
template_name="product_post",
variables={
"product_name": product["name"],
"product_description": product.get("description", ""),
"price": product["price"],
"category": product["category"],
"specs": json.dumps(product.get("specs", {}), ensure_ascii=False),
"highlights": ", ".join(product.get("highlights", [])),
},
platform=platform,
db=db,
personality="promotional",
**kwargs
)
async def generate_service_post(
self,
service: Dict[str, Any],
platform: str,
db: Optional[Session] = None,
**kwargs
) -> Dict[str, Any]:
"""
Generar post para un servicio.
Args:
service: Dict con datos del servicio
platform: Plataforma destino
db: Sesión de DB
**kwargs: Argumentos adicionales
Returns:
Dict con contenido generado
"""
return await self.generate(
template_name="service_post",
variables={
"service_name": service["name"],
"service_description": service.get("description", ""),
"category": service["category"],
"target_sectors": ", ".join(service.get("target_sectors", [])),
"benefits": ", ".join(service.get("benefits", [])),
"call_to_action": service.get("call_to_action", "Contáctanos"),
},
platform=platform,
db=db,
personality="promotional",
**kwargs
)
async def generate_thread(
self,
topic: str,
num_posts: int = 5,
db: Optional[Session] = None,
**kwargs
) -> List[str]:
"""
Generar un hilo educativo.
Args:
topic: Tema del hilo
num_posts: Número de posts en el hilo
db: Sesión de DB
**kwargs: Argumentos adicionales
Returns:
Lista de posts del hilo
"""
result = await self.generate(
template_name="thread",
variables={
"topic": topic,
"num_posts": num_posts,
"depth": "intermedio",
},
platform="x", # Threads son para X principalmente
db=db,
personality="educational",
**kwargs
)
# Parsear posts del hilo
content = result["content"]
posts = [p.strip() for p in content.split("\n\n") if p.strip()]
# Si no se separaron bien, intentar por números
if len(posts) < num_posts - 1:
import re
posts = re.split(r"\n(?=\d+[/\)])", content)
posts = [p.strip() for p in posts if p.strip()]
return posts
async def generate_response(
self,
interaction_content: str,
interaction_type: str,
sentiment: str = "neutral",
context: Optional[str] = None,
**kwargs
) -> List[str]:
"""
Generar sugerencias de respuesta.
Args:
interaction_content: Contenido de la interacción
interaction_type: Tipo (comment, mention, reply, dm)
sentiment: Sentimiento detectado
context: Contexto adicional
**kwargs: Argumentos adicionales
Returns:
Lista de 3 opciones de respuesta
"""
result = await self.generate(
template_name="response",
variables={
"interaction_content": interaction_content,
"interaction_type": interaction_type,
"sentiment": sentiment,
"context": context or "Sin contexto adicional",
},
platform="x",
use_context=False,
use_few_shot=False,
**kwargs
)
# Parsear las 3 opciones
content = result["content"]
lines = [l.strip() for l in content.split("\n") if l.strip()]
# Limpiar numeración
responses = []
for line in lines:
if line and line[0].isdigit() and len(line) > 2:
# Remover "1.", "1)", "1:" etc.
clean = line[2:].strip() if line[1] in ".):,-" else line
if clean:
responses.append(clean)
elif line and not line[0].isdigit():
responses.append(line)
return responses[:3]
async def adapt_content(
self,
content: str,
source_platform: str,
target_platform: str
) -> str:
"""
Adaptar contenido existente a otra plataforma usando IA.
Args:
content: Contenido original
source_platform: Plataforma de origen
target_platform: Plataforma destino
Returns:
Contenido adaptado
"""
# Generar prompt de adaptación
adaptation_prompt = self.plt_adapter.get_adaptation_prompt(
content, source_platform, target_platform
)
system_prompt = self.prompt_lib.get_system_prompt()
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": adaptation_prompt}
],
max_tokens=500,
temperature=0.6
)
return response.choices[0].message.content.strip()
async def generate_for_all_platforms(
self,
template_name: str,
variables: Dict[str, Any],
platforms: List[str],
db: Optional[Session] = None,
**kwargs
) -> Dict[str, Dict[str, Any]]:
"""
Generar contenido para múltiples plataformas.
Genera una versión base y la adapta para cada plataforma.
Args:
template_name: Nombre del template
variables: Variables del template
platforms: Lista de plataformas
db: Sesión de DB
**kwargs: Argumentos adicionales
Returns:
Dict de plataforma -> resultado de generación
"""
results = {}
# Generar versión base (para la plataforma con más espacio)
base_platform = "instagram" # Más espacio = más contexto
if "facebook" in platforms:
base_platform = "facebook"
base_result = await self.generate(
template_name=template_name,
variables=variables,
platform=base_platform,
db=db,
**kwargs
)
base_content = base_result["content"]
results[base_platform] = base_result
# Adaptar para otras plataformas
for platform in platforms:
if platform == base_platform:
continue
# Usar adaptación con IA para mejor calidad
adapted_content = await self.adapt_content(
base_content,
base_platform,
platform
)
# Validar adaptación
adapted = self.plt_adapter.adapt(adapted_content, platform)
results[platform] = {
"content": adapted_content,
"adapted_content": adapted.content,
"truncated": adapted.truncated,
"metadata": {
"template": template_name,
"platform": platform,
"adapted_from": base_platform,
"changes_made": adapted.changes_made,
}
}
return results
# Instancia global
content_generator_v2 = ContentGeneratorV2()

View File

@@ -0,0 +1,374 @@
"""
PlatformAdapter - Adapta contenido según reglas de cada plataforma.
Este módulo maneja:
- Transformación de contenido base a formato específico de plataforma
- Ajuste de longitud, tono, hashtags y formato
- Validación de límites de plataforma
"""
from typing import Dict, Optional, Any, List
from dataclasses import dataclass
from app.services.ai.prompt_library import prompt_library
@dataclass
class AdaptedContent:
"""Contenido adaptado para una plataforma."""
content: str
platform: str
original_content: str
truncated: bool
hashtags_adjusted: bool
changes_made: List[str]
class PlatformAdapter:
"""
Adaptador de contenido por plataforma.
Transforma contenido generado según las reglas específicas
de cada red social (X, Threads, Instagram, Facebook).
"""
def __init__(self):
"""Inicializar el adaptador."""
self.prompt_lib = prompt_library
# Límites rápidos (fallback si no hay YAML)
self._default_limits = {
"x": {"max_characters": 280, "max_hashtags": 2},
"threads": {"max_characters": 500, "max_hashtags": 5},
"instagram": {"max_characters": 2200, "max_hashtags": 10},
"facebook": {"max_characters": 2000, "max_hashtags": 3},
}
def get_limits(self, platform: str) -> Dict[str, int]:
"""
Obtener límites de una plataforma.
Args:
platform: Nombre de la plataforma
Returns:
Dict con límites
"""
try:
return self.prompt_lib.get_platform_limits(platform)
except FileNotFoundError:
return self._default_limits.get(platform, self._default_limits["x"])
def get_config(self, platform: str) -> Dict:
"""
Obtener configuración completa de una plataforma.
Args:
platform: Nombre de la plataforma
Returns:
Dict con configuración
"""
try:
return self.prompt_lib.get_platform_config(platform)
except FileNotFoundError:
return {"platform": platform, "limits": self.get_limits(platform)}
# === Adaptación Principal ===
def adapt(
self,
content: str,
platform: str,
preserve_hashtags: bool = True
) -> AdaptedContent:
"""
Adaptar contenido a una plataforma específica.
Esta es adaptación basada en reglas (sin IA).
Para adaptación con IA, usar adapt_with_ai().
Args:
content: Contenido a adaptar
platform: Plataforma destino
preserve_hashtags: Si preservar hashtags existentes
Returns:
AdaptedContent con el contenido adaptado
"""
limits = self.get_limits(platform)
config = self.get_config(platform)
changes = []
adapted = content
truncated = False
hashtags_adjusted = False
# 1. Extraer y procesar hashtags
main_content, hashtags = self._extract_hashtags(adapted)
# 2. Ajustar hashtags según plataforma
max_hashtags = limits.get("max_hashtags", 5)
if len(hashtags) > max_hashtags:
hashtags = hashtags[:max_hashtags]
hashtags_adjusted = True
changes.append(f"Hashtags reducidos a {max_hashtags}")
# 3. Verificar y ajustar longitud
max_chars = limits.get("max_characters", 2000)
# Calcular espacio para hashtags
hashtag_space = len(" ".join(hashtags)) + 2 if hashtags else 0
available_for_content = max_chars - hashtag_space
if len(main_content) > available_for_content:
main_content = self._smart_truncate(main_content, available_for_content)
truncated = True
changes.append(f"Contenido truncado a {available_for_content} caracteres")
# 4. Aplicar formato de plataforma
main_content = self._apply_platform_formatting(main_content, platform, config)
if main_content != content.replace("#", "").strip():
changes.append("Formato ajustado para plataforma")
# 5. Recombinar con hashtags
if hashtags and preserve_hashtags:
adapted = main_content + "\n\n" + " ".join(hashtags)
else:
adapted = main_content
return AdaptedContent(
content=adapted.strip(),
platform=platform,
original_content=content,
truncated=truncated,
hashtags_adjusted=hashtags_adjusted,
changes_made=changes,
)
def adapt_for_all_platforms(
self,
content: str,
platforms: List[str]
) -> Dict[str, AdaptedContent]:
"""
Adaptar contenido para múltiples plataformas.
Args:
content: Contenido base
platforms: Lista de plataformas
Returns:
Dict de plataforma -> AdaptedContent
"""
return {
platform: self.adapt(content, platform)
for platform in platforms
}
# === Helpers ===
def _extract_hashtags(self, content: str) -> tuple[str, List[str]]:
"""
Extraer hashtags del contenido.
Returns:
Tuple de (contenido sin hashtags, lista de hashtags)
"""
import re
hashtags = re.findall(r"#\w+", content)
# Remover hashtags del contenido
main_content = re.sub(r"\s*#\w+", "", content).strip()
return main_content, hashtags
def _smart_truncate(self, content: str, max_length: int) -> str:
"""
Truncar contenido de forma inteligente.
Intenta cortar en un punto natural (oración, párrafo).
Args:
content: Contenido a truncar
max_length: Longitud máxima
Returns:
Contenido truncado
"""
if len(content) <= max_length:
return content
# Reservar espacio para "..."
target_length = max_length - 3
# Intentar cortar en punto/salto de línea
truncated = content[:target_length]
# Buscar último punto o salto de línea
last_period = truncated.rfind(".")
last_newline = truncated.rfind("\n")
cut_point = max(last_period, last_newline)
if cut_point > target_length * 0.5: # Solo si no perdemos mucho
return content[:cut_point + 1].strip()
# Si no hay buen punto de corte, cortar en última palabra completa
last_space = truncated.rfind(" ")
if last_space > target_length * 0.7:
return content[:last_space].strip() + "..."
return truncated + "..."
def _apply_platform_formatting(
self,
content: str,
platform: str,
config: Dict
) -> str:
"""
Aplicar formato específico de plataforma.
Args:
content: Contenido a formatear
platform: Plataforma
config: Configuración de la plataforma
Returns:
Contenido formateado
"""
formatting = config.get("formatting", {})
# Aplicar estilo de bullets si está configurado
bullet_style = formatting.get("bullet_style", "")
# Reemplazar bullets genéricos
content = content.replace("", f"{bullet_style} ")
content = content.replace("- ", f"{bullet_style} ")
# Ajustar saltos de línea según plataforma
if platform == "x":
# X: menos saltos de línea, más compacto
content = self._compact_line_breaks(content)
elif platform == "instagram":
# Instagram: más saltos para legibilidad
content = self._expand_line_breaks(content)
return content
def _compact_line_breaks(self, content: str) -> str:
"""Reducir saltos de línea múltiples a uno."""
import re
return re.sub(r"\n{3,}", "\n\n", content)
def _expand_line_breaks(self, content: str) -> str:
"""Asegurar separación entre párrafos."""
import re
# Reemplazar un salto por dos donde hay oraciones
return re.sub(r"\.(\n)(?=[A-Z])", ".\n\n", content)
# === Validación ===
def validate_for_platform(
self,
content: str,
platform: str
) -> Dict[str, Any]:
"""
Validar que contenido cumple con límites de plataforma.
Args:
content: Contenido a validar
platform: Plataforma
Returns:
Dict con resultado de validación
"""
limits = self.get_limits(platform)
_, hashtags = self._extract_hashtags(content)
issues = []
# Verificar longitud
max_chars = limits.get("max_characters", 2000)
if len(content) > max_chars:
issues.append({
"type": "length",
"message": f"Contenido excede límite ({len(content)}/{max_chars})",
"severity": "error"
})
# Verificar hashtags
max_hashtags = limits.get("max_hashtags", 10)
if len(hashtags) > max_hashtags:
issues.append({
"type": "hashtags",
"message": f"Demasiados hashtags ({len(hashtags)}/{max_hashtags})",
"severity": "warning"
})
return {
"valid": len([i for i in issues if i["severity"] == "error"]) == 0,
"issues": issues,
"stats": {
"characters": len(content),
"max_characters": max_chars,
"hashtags": len(hashtags),
"max_hashtags": max_hashtags,
}
}
# === Generación de Prompts de Adaptación ===
def get_adaptation_prompt(
self,
content: str,
source_platform: str,
target_platform: str
) -> str:
"""
Generar prompt para adaptar contenido con IA.
Args:
content: Contenido original
source_platform: Plataforma de origen
target_platform: Plataforma destino
Returns:
Prompt para enviar a la IA
"""
target_config = self.get_config(target_platform)
limits = self.get_limits(target_platform)
adaptation_rules = target_config.get("adaptation_rules", "")
tone_style = target_config.get("tone", {}).get("style", "neutral")
prompt = f"""Adapta este contenido de {source_platform} para {target_platform}:
CONTENIDO ORIGINAL:
{content}
LÍMITES DE {target_platform.upper()}:
- Máximo caracteres: {limits.get('max_characters', 2000)}
- Máximo hashtags: {limits.get('max_hashtags', 5)}
TONO PARA {target_platform.upper()}: {tone_style}
REGLAS DE ADAPTACIÓN:
{adaptation_rules}
IMPORTANTE:
- Mantén la esencia y mensaje principal
- Adapta el tono según la plataforma
- Ajusta hashtags apropiadamente
- NO inventes información nueva
Responde SOLO con el contenido adaptado, sin explicaciones."""
return prompt
# Instancia global
platform_adapter = PlatformAdapter()

View File

@@ -0,0 +1,353 @@
"""
PromptLibrary - Carga y renderiza prompts desde archivos YAML.
Este módulo maneja:
- Carga de templates de prompts desde YAML
- Renderizado con variables dinámicas
- Herencia de personalidades
- Cache para evitar lecturas repetidas de disco
"""
import os
from pathlib import Path
from typing import Dict, Optional, Any, List
from functools import lru_cache
import yaml
from app.core.config import settings
class PromptLibrary:
"""
Biblioteca de prompts cargados desde YAML.
Estructura de directorios esperada:
app/prompts/
├── personalities/ # Personalidades de marca
├── templates/ # Templates de contenido
├── examples/ # Ejemplos para few-shot
└── platforms/ # Configuración por plataforma
"""
def __init__(self, prompts_dir: Optional[str] = None):
"""
Inicializar la biblioteca de prompts.
Args:
prompts_dir: Directorio raíz de prompts. Si no se especifica,
usa app/prompts/ relativo al proyecto.
"""
if prompts_dir:
self.prompts_dir = Path(prompts_dir)
else:
# Detectar directorio del proyecto
base_dir = Path(__file__).parent.parent.parent # app/services/ai -> app
self.prompts_dir = base_dir / "prompts"
self._cache: Dict[str, Any] = {}
def _load_yaml(self, file_path: Path) -> Dict:
"""
Cargar archivo YAML con cache.
Args:
file_path: Ruta al archivo YAML
Returns:
Diccionario con contenido del YAML
"""
cache_key = str(file_path)
if cache_key not in self._cache:
if not file_path.exists():
raise FileNotFoundError(f"Archivo de prompt no encontrado: {file_path}")
with open(file_path, "r", encoding="utf-8") as f:
self._cache[cache_key] = yaml.safe_load(f)
return self._cache[cache_key]
def clear_cache(self) -> None:
"""Limpiar cache de YAMLs cargados."""
self._cache.clear()
# === Personalidades ===
def get_personality(self, name: str = "default") -> Dict:
"""
Obtener una personalidad de marca.
Args:
name: Nombre de la personalidad (default, educational, promotional)
Returns:
Dict con configuración de personalidad
"""
file_path = self.prompts_dir / "personalities" / f"{name}.yaml"
personality = self._load_yaml(file_path)
# Si hereda de otra personalidad, mergear
if "extends" in personality:
base_name = personality["extends"]
base = self.get_personality(base_name)
personality = self._merge_personalities(base, personality)
return personality
def _merge_personalities(self, base: Dict, child: Dict) -> Dict:
"""
Mergear personalidad hija con base.
La hija sobrescribe valores de la base.
"""
merged = base.copy()
for key, value in child.items():
if key == "extends":
continue
if isinstance(value, dict) and key in merged and isinstance(merged[key], dict):
merged[key] = {**merged[key], **value}
else:
merged[key] = value
return merged
def get_system_prompt(self, personality: str = "default") -> str:
"""
Obtener system prompt para una personalidad.
Args:
personality: Nombre de la personalidad
Returns:
System prompt renderizado con variables de negocio
"""
pers = self.get_personality(personality)
system_prompt = pers.get("system_prompt", "")
# Si tiene system_prompt_addition (de herencia), agregarlo
if "system_prompt_addition" in pers:
system_prompt += pers["system_prompt_addition"]
# Renderizar variables de negocio
system_prompt = system_prompt.format(
brand_name=settings.BUSINESS_NAME,
location=settings.BUSINESS_LOCATION,
website=settings.BUSINESS_WEBSITE,
tone=settings.CONTENT_TONE,
)
return system_prompt
# === Templates ===
def get_template(self, name: str) -> Dict:
"""
Obtener un template de contenido.
Args:
name: Nombre del template (tip_tech, product_post, etc.)
Returns:
Dict con configuración del template
"""
file_path = self.prompts_dir / "templates" / f"{name}.yaml"
return self._load_yaml(file_path)
def render_template(
self,
template_name: str,
variables: Dict[str, Any],
personality: str = "default"
) -> Dict[str, Any]:
"""
Renderizar un template con variables.
Args:
template_name: Nombre del template
variables: Variables para sustituir en el template
personality: Personalidad a usar
Returns:
Dict con:
- system_prompt: Prompt del sistema
- user_prompt: Prompt del usuario renderizado
- parameters: Parámetros para la API (temperature, max_tokens)
"""
template = self.get_template(template_name)
# Obtener system prompt de la personalidad especificada en template o parámetro
pers_name = template.get("personality", personality)
system_prompt = self.get_system_prompt(pers_name)
# Agregar contexto del template al system prompt si existe
if "system_context" in template:
system_prompt += "\n\n" + template["system_context"]
# Obtener y renderizar el user prompt
user_template = template.get("template", "")
# Aplicar defaults a variables faltantes
template_vars = {}
for var_def in template.get("variables", []):
var_name = var_def["name"]
if var_name in variables:
template_vars[var_name] = variables[var_name]
elif "default" in var_def:
template_vars[var_name] = var_def["default"]
elif var_def.get("required", False):
raise ValueError(f"Variable requerida no proporcionada: {var_name}")
# Renderizar template
try:
user_prompt = user_template.format(**template_vars)
except KeyError as e:
raise ValueError(f"Variable faltante en template: {e}")
# Obtener parámetros
parameters = template.get("parameters", {})
return {
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"parameters": parameters,
"template_name": template_name,
"personality": pers_name,
}
# === Plataformas ===
def get_platform_config(self, platform: str) -> Dict:
"""
Obtener configuración de una plataforma.
Args:
platform: Nombre de la plataforma (x, threads, instagram, facebook)
Returns:
Dict con configuración de la plataforma
"""
file_path = self.prompts_dir / "platforms" / f"{platform}.yaml"
return self._load_yaml(file_path)
def get_platform_limits(self, platform: str) -> Dict:
"""
Obtener límites de una plataforma.
Args:
platform: Nombre de la plataforma
Returns:
Dict con límites (max_characters, max_hashtags, etc.)
"""
config = self.get_platform_config(platform)
return config.get("limits", {})
def get_platform_adaptation_rules(self, platform: str) -> str:
"""
Obtener reglas de adaptación para una plataforma.
Args:
platform: Nombre de la plataforma
Returns:
String con reglas de adaptación
"""
config = self.get_platform_config(platform)
return config.get("adaptation_rules", "")
# === Ejemplos ===
def get_examples(self, category: Optional[str] = None) -> Dict:
"""
Obtener ejemplos para few-shot learning.
Args:
category: Categoría específica (ia, productividad, seguridad)
Si es None, retorna best_posts.yaml
Returns:
Dict con ejemplos
"""
if category:
file_path = self.prompts_dir / "examples" / "by_category" / f"{category}.yaml"
else:
file_path = self.prompts_dir / "examples" / "best_posts.yaml"
return self._load_yaml(file_path)
def get_few_shot_examples(
self,
template_type: str,
category: Optional[str] = None,
max_examples: int = 3
) -> List[str]:
"""
Obtener ejemplos formateados para few-shot prompting.
Args:
template_type: Tipo de template (tip_tech, product_post, etc.)
category: Categoría de contenido (opcional)
max_examples: Máximo de ejemplos a retornar
Returns:
Lista de strings con ejemplos formateados
"""
examples = []
# Primero intentar ejemplos de categoría específica
if category:
try:
cat_examples = self.get_examples(category)
if "examples" in cat_examples:
for example_type, example_list in cat_examples["examples"].items():
for ex in example_list[:max_examples]:
if "content" in ex:
examples.append(ex["content"])
except FileNotFoundError:
pass
# Luego best_posts generales
if len(examples) < max_examples:
try:
best = self.get_examples()
if "examples" in best and template_type in best["examples"]:
for ex in best["examples"][template_type]:
if len(examples) >= max_examples:
break
if "content" in ex:
examples.append(ex["content"])
elif "posts" in ex: # Para threads
examples.append("\n\n".join(ex["posts"]))
except FileNotFoundError:
pass
return examples[:max_examples]
# === Utilidades ===
def list_templates(self) -> List[str]:
"""Listar todos los templates disponibles."""
templates_dir = self.prompts_dir / "templates"
if not templates_dir.exists():
return []
return [f.stem for f in templates_dir.glob("*.yaml")]
def list_personalities(self) -> List[str]:
"""Listar todas las personalidades disponibles."""
pers_dir = self.prompts_dir / "personalities"
if not pers_dir.exists():
return []
return [f.stem for f in pers_dir.glob("*.yaml")]
def list_platforms(self) -> List[str]:
"""Listar todas las plataformas configuradas."""
platforms_dir = self.prompts_dir / "platforms"
if not platforms_dir.exists():
return []
return [f.stem for f in platforms_dir.glob("*.yaml")]
# Instancia global
prompt_library = PromptLibrary()

View File

@@ -0,0 +1,479 @@
"""
ContentValidator - Validación y scoring de contenido con IA.
Este módulo maneja:
- Validaciones obligatorias (pass/fail)
- Scoring de calidad con IA
- Decisiones de regeneración
- Marcado de top performers
"""
import json
import re
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from dataclasses import dataclass
from openai import OpenAI
import yaml
from app.core.config import settings
from app.services.ai.platform_adapter import platform_adapter
@dataclass
class ValidationResult:
"""Resultado de validación."""
passed: bool
issues: List[Dict[str, Any]]
content: str
@dataclass
class ScoringResult:
"""Resultado de scoring."""
total_score: int
breakdown: Dict[str, int]
feedback: str
is_top_performer: bool
action: str # "accept", "regenerate", "reject"
@dataclass
class ContentQualityResult:
"""Resultado completo de validación y scoring."""
validation: ValidationResult
scoring: Optional[ScoringResult]
final_decision: str # "accept", "regenerate", "reject"
content: str
class ContentValidator:
"""
Validador de contenido generado.
Combina validaciones basadas en reglas (rápidas, sin costo)
con scoring usando IA (más preciso, con costo de tokens).
"""
def __init__(self, config_path: Optional[str] = None):
"""
Inicializar el validador.
Args:
config_path: Ruta al archivo quality.yaml
"""
self._client = None
self.model = "deepseek-chat"
# Cargar configuración
if config_path:
self.config_path = Path(config_path)
else:
base_dir = Path(__file__).parent.parent.parent
self.config_path = base_dir / "config" / "quality.yaml"
self.config = self._load_config()
def _load_config(self) -> Dict:
"""Cargar configuración de quality.yaml."""
if self.config_path.exists():
with open(self.config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
# Config por defecto si no existe el archivo
return {
"thresholds": {
"minimum_score": 60,
"excellent_score": 85,
},
"regeneration": {
"max_attempts": 2,
},
"validations": {
"prohibited_content": {
"prohibited_words": [],
"prohibited_patterns": [],
}
}
}
@property
def client(self) -> OpenAI:
"""Lazy initialization del cliente."""
if self._client is None:
if not settings.DEEPSEEK_API_KEY:
raise ValueError("DEEPSEEK_API_KEY no configurada")
self._client = OpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url=settings.DEEPSEEK_BASE_URL
)
return self._client
# === Validaciones (Pass/Fail) ===
def validate(
self,
content: str,
platform: str,
expected_language: str = "es"
) -> ValidationResult:
"""
Ejecutar validaciones obligatorias.
Args:
content: Contenido a validar
platform: Plataforma destino
expected_language: Idioma esperado
Returns:
ValidationResult con resultado de validaciones
"""
issues = []
# 1. Validar longitud
length_result = self._validate_length(content, platform)
if not length_result["passed"]:
issues.append(length_result)
# 2. Validar contenido prohibido
prohibited_result = self._validate_prohibited_content(content)
if not prohibited_result["passed"]:
issues.append(prohibited_result)
# 3. Validar formato
format_result = self._validate_format(content)
if not format_result["passed"]:
issues.append(format_result)
# 4. Validar que no esté vacío o muy corto
if len(content.strip()) < 20:
issues.append({
"type": "empty_content",
"message": "Contenido demasiado corto",
"severity": "error",
"passed": False
})
passed = all(i.get("severity") != "error" for i in issues)
return ValidationResult(
passed=passed,
issues=issues,
content=content
)
def _validate_length(self, content: str, platform: str) -> Dict:
"""Validar longitud contra límites de plataforma."""
limits = platform_adapter.get_limits(platform)
max_chars = limits.get("max_characters", 2000)
if len(content) > max_chars:
return {
"type": "length",
"message": f"Contenido excede límite: {len(content)}/{max_chars}",
"severity": "error",
"passed": False,
"current": len(content),
"max": max_chars
}
return {"type": "length", "passed": True}
def _validate_prohibited_content(self, content: str) -> Dict:
"""Validar que no contenga palabras/patrones prohibidos."""
validations = self.config.get("validations", {})
prohibited = validations.get("prohibited_content", {})
content_lower = content.lower()
# Verificar palabras prohibidas
prohibited_words = prohibited.get("prohibited_words", [])
for word in prohibited_words:
if word.lower() in content_lower:
return {
"type": "prohibited_content",
"message": f"Contenido contiene palabra prohibida: {word}",
"severity": "error",
"passed": False,
"word": word
}
# Verificar patrones prohibidos
prohibited_patterns = prohibited.get("prohibited_patterns", [])
for pattern in prohibited_patterns:
if re.search(pattern, content_lower):
return {
"type": "prohibited_pattern",
"message": f"Contenido coincide con patrón prohibido",
"severity": "error",
"passed": False,
"pattern": pattern
}
return {"type": "prohibited_content", "passed": True}
def _validate_format(self, content: str) -> Dict:
"""Validar formato del contenido."""
issues = []
# Verificar que no esté truncado (terminando en medio de palabra)
if content and not content[-1] in ".!?\"')#\n":
# Podría estar truncado
last_word = content.split()[-1] if content.split() else ""
if len(last_word) > 15: # Palabra muy larga al final = truncado
issues.append("Posiblemente truncado")
# Verificar encoding (caracteres extraños)
try:
content.encode("utf-8").decode("utf-8")
except Exception:
issues.append("Problemas de encoding")
if issues:
return {
"type": "format",
"message": "; ".join(issues),
"severity": "warning",
"passed": True # Warning, no error
}
return {"type": "format", "passed": True}
# === Scoring con IA ===
async def score(
self,
content: str,
platform: str
) -> ScoringResult:
"""
Evaluar calidad del contenido usando IA.
Args:
content: Contenido a evaluar
platform: Plataforma
Returns:
ScoringResult con score y breakdown
"""
# Obtener prompt de scoring del config
scoring_prompt = self.config.get("scoring_prompt", "")
if not scoring_prompt:
scoring_prompt = self._default_scoring_prompt()
# Renderizar prompt
prompt = scoring_prompt.format(
content=content,
platform=platform
)
# Llamar a DeepSeek
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "system",
"content": "Eres un evaluador de contenido para redes sociales. "
"Evalúa de forma objetiva y estricta. "
"Responde SOLO en JSON válido."
},
{"role": "user", "content": prompt}
],
max_tokens=300,
temperature=0.3 # Bajo para consistencia
)
response_text = response.choices[0].message.content.strip()
# Parsear respuesta JSON
try:
# Limpiar respuesta si tiene markdown
if "```json" in response_text:
response_text = response_text.split("```json")[1].split("```")[0]
elif "```" in response_text:
response_text = response_text.split("```")[1].split("```")[0]
result = json.loads(response_text)
except json.JSONDecodeError:
# Si falla el parsing, intentar extraer números
result = self._extract_score_from_text(response_text)
total_score = result.get("total", 50)
breakdown = result.get("breakdown", {})
feedback = result.get("feedback", "")
# Determinar acción
thresholds = self.config.get("thresholds", {})
min_score = thresholds.get("minimum_score", 60)
excellent_score = thresholds.get("excellent_score", 85)
if total_score < 40:
action = "reject"
elif total_score < min_score:
action = "regenerate"
else:
action = "accept"
is_top = total_score >= excellent_score
return ScoringResult(
total_score=total_score,
breakdown=breakdown,
feedback=feedback,
is_top_performer=is_top,
action=action
)
def _default_scoring_prompt(self) -> str:
"""Prompt por defecto para scoring."""
return """Evalúa este post para {platform} en escala 0-100.
POST:
{content}
CRITERIOS (suma = 100):
- Hook (0-25): ¿La primera línea captura atención?
- Claridad (0-20): ¿Se entiende fácilmente?
- Accionabilidad (0-20): ¿Qué puede hacer el lector?
- Originalidad (0-15): ¿Evita clichés?
- Voz de marca (0-10): ¿Profesional pero cercano?
- CTA (0-10): ¿CTA claro si aplica?
RESPONDE EN JSON:
{{"total": N, "breakdown": {{"hook_strength": N, "clarity": N, "actionability": N, "originality": N, "brand_voice": N, "cta_effectiveness": N}}, "feedback": "sugerencia"}}"""
def _extract_score_from_text(self, text: str) -> Dict:
"""Extraer score de texto si falla JSON parsing."""
# Buscar patrones como "total: 75" o "score: 75"
import re
total_match = re.search(r"total[:\s]+(\d+)", text.lower())
total = int(total_match.group(1)) if total_match else 50
return {
"total": min(100, max(0, total)),
"breakdown": {},
"feedback": "No se pudo parsear respuesta completa"
}
# === Evaluación Completa ===
async def evaluate(
self,
content: str,
platform: str,
skip_scoring: bool = False
) -> ContentQualityResult:
"""
Evaluación completa: validación + scoring.
Args:
content: Contenido a evaluar
platform: Plataforma
skip_scoring: Si omitir scoring (solo validación)
Returns:
ContentQualityResult con resultado completo
"""
# 1. Validaciones obligatorias
validation = self.validate(content, platform)
# Si falla validación, no hace falta scoring
if not validation.passed:
return ContentQualityResult(
validation=validation,
scoring=None,
final_decision="reject",
content=content
)
# 2. Scoring con IA (si no se omite)
scoring = None
if not skip_scoring:
scoring = await self.score(content, platform)
# 3. Decisión final
if scoring:
final_decision = scoring.action
else:
final_decision = "accept" # Sin scoring, aceptar si pasó validación
return ContentQualityResult(
validation=validation,
scoring=scoring,
final_decision=final_decision,
content=content
)
# === Utilidades ===
def should_regenerate(
self,
quality_result: ContentQualityResult,
attempt: int = 1
) -> bool:
"""
Determinar si se debe regenerar el contenido.
Args:
quality_result: Resultado de evaluación
attempt: Número de intento actual
Returns:
True si se debe regenerar
"""
max_attempts = self.config.get("regeneration", {}).get("max_attempts", 2)
if attempt >= max_attempts:
return False
return quality_result.final_decision == "regenerate"
def get_regeneration_hints(
self,
quality_result: ContentQualityResult
) -> str:
"""
Obtener hints para mejorar en la regeneración.
Args:
quality_result: Resultado de evaluación
Returns:
String con instrucciones para mejorar
"""
hints = []
# Hints de validación
for issue in quality_result.validation.issues:
if issue.get("type") == "length":
hints.append(f"Reducir longitud a máximo {issue.get('max')} caracteres")
elif issue.get("type") == "prohibited_content":
hints.append(f"Evitar: {issue.get('word', 'contenido prohibido')}")
# Hints de scoring
if quality_result.scoring:
if quality_result.scoring.feedback:
hints.append(quality_result.scoring.feedback)
# Identificar áreas débiles
breakdown = quality_result.scoring.breakdown
if breakdown:
weak_areas = []
if breakdown.get("hook_strength", 25) < 15:
weak_areas.append("mejorar el hook inicial")
if breakdown.get("clarity", 20) < 12:
weak_areas.append("hacer el mensaje más claro")
if breakdown.get("actionability", 20) < 12:
weak_areas.append("hacerlo más accionable")
if weak_areas:
hints.append("Enfocarse en: " + ", ".join(weak_areas))
if hints:
return "\n\nPARA MEJORAR:\n- " + "\n- ".join(hints)
return ""
# Instancia global
content_validator = ContentValidator()

View File

@@ -1,20 +1,65 @@
"""
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
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."""
"""
Generador de contenido usando DeepSeek API.
def __init__(self):
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):
@@ -30,6 +75,15 @@ class ContentGenerator:
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:
@@ -54,13 +108,70 @@ REGLAS:
- 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:
"""Generar un tip tech."""
"""Implementación original de generate_tip_tech."""
char_limits = {
"x": 280,
"threads": 500,
@@ -97,11 +208,55 @@ Responde SOLO con el texto del post, sin explicaciones."""
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:
"""Generar post para un producto."""
"""Implementación original de generate_product_post."""
char_limits = {
"x": 280,
"threads": 500,
@@ -144,11 +299,55 @@ Responde SOLO con el texto del post."""
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:
"""Generar post para un servicio."""
"""Implementación original de generate_service_post."""
char_limits = {
"x": 280,
"threads": 500,
@@ -191,11 +390,38 @@ Responde SOLO con el texto del post."""
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]:
"""Generar un hilo educativo."""
"""Implementación original de generate_thread."""
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
REQUISITOS:
@@ -222,7 +448,6 @@ Responde con cada post en una línea separada, sin explicaciones."""
)
content = response.choices[0].message.content.strip()
# Separar posts por líneas no vacías
posts = [p.strip() for p in content.split('\n') if p.strip()]
return posts
@@ -233,7 +458,36 @@ Responde con cada post en una línea separada, sin explicaciones."""
interaction_type: str,
context: Optional[str] = None
) -> List[str]:
"""Generar sugerencias de respuesta para una interacción."""
"""
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}"
@@ -267,21 +521,46 @@ Responde con las 3 opciones numeradas, una por línea."""
content = response.choices[0].message.content.strip()
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
# Limpiar numeración si existe
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] # Máximo 3 sugerencias
return cleaned[:3]
async def adapt_content_for_platform(
self,
content: str,
target_platform: str
) -> str:
"""Adaptar contenido existente a una plataforma específica."""
"""
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,
@@ -318,6 +597,119 @@ Responde SOLO con el contenido adaptado."""
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()